Skip to main content

nautilus_core/string/
parsing.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Core parsing functions.
17
18/// Clamps a length to `u8::MAX` with optional debug logging.
19#[inline]
20#[must_use]
21#[expect(
22    clippy::cast_possible_truncation,
23    reason = "Intentional for parsing, value range validated"
24)]
25fn clamp_precision_with_log(len: usize, context: &str, input: &str) -> u8 {
26    if len > u8::MAX as usize {
27        log::debug!(
28            "{} precision clamped from {} to {} for input: {}",
29            context,
30            len,
31            u8::MAX,
32            input
33        );
34    }
35    len.min(u8::MAX as usize) as u8
36}
37
38/// Parses a scientific notation exponent and clamps to `u8::MAX`.
39///
40/// Returns `None` for invalid/empty exponents when `strict` is false,
41/// otherwise panics for malformed input.
42#[inline]
43#[must_use]
44#[expect(
45    clippy::cast_possible_truncation,
46    reason = "value is clamped to u8::MAX before the cast"
47)]
48fn parse_scientific_exponent(exponent_str: &str, strict: bool) -> Option<u8> {
49    if let Ok(exp) = exponent_str.parse::<u64>() {
50        Some(exp.min(u64::from(u8::MAX)) as u8)
51    } else {
52        assert!(
53            !(exponent_str.is_empty() && strict),
54            "Invalid scientific notation format: missing exponent after 'e-'"
55        );
56
57        // Empty string is invalid (not a large number that overflowed)
58        if exponent_str.is_empty() {
59            return None;
60        }
61
62        // If it's all digits but overflows u64, clamp to u8::MAX
63        if exponent_str.chars().all(|c| c.is_ascii_digit()) {
64            Some(u8::MAX)
65        } else if strict {
66            panic!("Invalid scientific notation exponent '{exponent_str}': must be a valid number")
67        } else {
68            None
69        }
70    }
71}
72
73/// Returns the decimal precision inferred from the given string.
74///
75/// For scientific notation with large negative exponents (e.g., "1e-300", "1e-4294967296"),
76/// the precision is clamped to `u8::MAX` (255) since that represents the maximum representable
77/// precision in this system. This handles arbitrarily large exponents without panicking.
78///
79/// # Panics
80///
81/// Panics if the input string is malformed (e.g., "1e-" with no exponent value, or non-numeric
82/// exponents like "1e-abc").
83#[must_use]
84pub fn precision_from_str(s: &str) -> u8 {
85    let s = s.trim().to_ascii_lowercase();
86
87    // Check for scientific notation
88    if s.contains("e-") {
89        let exponent_str = s
90            .split("e-")
91            .nth(1)
92            .expect("Invalid scientific notation format: missing exponent after 'e-'");
93
94        return parse_scientific_exponent(exponent_str, true)
95            .expect("parse_scientific_exponent should return Some in strict mode");
96    }
97
98    // Check for decimal precision
99    if let Some((_, decimal_part)) = s.split_once('.') {
100        clamp_precision_with_log(decimal_part.len(), "Decimal", &s)
101    } else {
102        0
103    }
104}
105
106/// Returns the minimum increment precision inferred from the given string,
107/// ignoring trailing zeros.
108///
109/// For scientific notation with large negative exponents (e.g., "1e-300"), the precision
110/// is clamped to `u8::MAX` (255) to match the behavior of `precision_from_str`.
111#[must_use]
112pub fn min_increment_precision_from_str(s: &str) -> u8 {
113    let s = s.trim().to_ascii_lowercase();
114
115    // Check for scientific notation
116    if let Some(pos) = s.find('e')
117        && s[pos + 1..].starts_with('-')
118    {
119        let exponent_str = &s[pos + 2..];
120        // Use lenient parsing (returns 0 for invalid, doesn't panic)
121        return parse_scientific_exponent(exponent_str, false).unwrap_or(0);
122    }
123
124    // Check for decimal precision
125    if let Some(dot_pos) = s.find('.') {
126        let decimal_part = &s[dot_pos + 1..];
127        if decimal_part.chars().any(|c| c != '0') {
128            let trimmed_len = decimal_part.trim_end_matches('0').len();
129            return clamp_precision_with_log(trimmed_len, "Minimum increment", &s);
130        }
131        clamp_precision_with_log(decimal_part.len(), "Decimal", &s)
132    } else {
133        0
134    }
135}
136
137/// Returns a `usize` from the given bytes.
138///
139/// Reads the first `size_of::<usize>()` bytes in little-endian order; any
140/// additional bytes are ignored.
141///
142/// # Errors
143///
144/// Returns an error if there are not enough bytes to represent a `usize`.
145pub fn bytes_to_usize(bytes: &[u8]) -> anyhow::Result<usize> {
146    // Check bytes width
147    if bytes.len() >= std::mem::size_of::<usize>() {
148        let mut buffer = [0u8; std::mem::size_of::<usize>()];
149        buffer.copy_from_slice(&bytes[..std::mem::size_of::<usize>()]);
150
151        Ok(usize::from_le_bytes(buffer))
152    } else {
153        anyhow::bail!("Not enough bytes to represent a `usize`");
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use rstest::rstest;
160
161    use super::*;
162
163    #[rstest]
164    #[case("", 0)]
165    #[case("0", 0)]
166    #[case("1.0", 1)]
167    #[case("1.00", 2)]
168    #[case("1.23456789", 8)]
169    #[case("123456.789101112", 9)]
170    #[case("0.000000001", 9)]
171    #[case("1e-1", 1)]
172    #[case("1e-2", 2)]
173    #[case("1e-3", 3)]
174    #[case("1e8", 0)]
175    #[case("-1.23", 2)]
176    #[case("-1e-2", 2)]
177    #[case("1E-2", 2)]
178    #[case("  1.23", 2)]
179    #[case("1.23  ", 2)]
180    fn test_precision_from_str(#[case] s: &str, #[case] expected: u8) {
181        let result = precision_from_str(s);
182        assert_eq!(result, expected);
183    }
184
185    #[rstest]
186    #[case("", 0)]
187    #[case("0", 0)]
188    #[case("1.0", 1)]
189    #[case("1.00", 2)]
190    #[case("1.23456789", 8)]
191    #[case("123456.789101112", 9)]
192    #[case("0.000000001", 9)]
193    #[case("1e-1", 1)]
194    #[case("1e-2", 2)]
195    #[case("1e-3", 3)]
196    #[case("1e8", 0)]
197    #[case("-1.23", 2)]
198    #[case("-1e-2", 2)]
199    #[case("1E-2", 2)]
200    #[case("  1.23", 2)]
201    #[case("1.23  ", 2)]
202    #[case("1.010", 2)]
203    #[case("1.00100", 3)]
204    #[case("0.0001000", 4)]
205    #[case("1.000000000", 9)]
206    fn test_min_increment_precision_from_str(#[case] s: &str, #[case] expected: u8) {
207        let result = min_increment_precision_from_str(s);
208        assert_eq!(result, expected);
209    }
210
211    #[rstest]
212    fn test_bytes_to_usize_empty() {
213        let payload: Vec<u8> = vec![];
214        let result = bytes_to_usize(&payload);
215        assert!(result.is_err());
216        assert_eq!(
217            result.err().unwrap().to_string(),
218            "Not enough bytes to represent a `usize`"
219        );
220    }
221
222    #[rstest]
223    fn test_bytes_to_usize_invalid() {
224        let payload: Vec<u8> = vec![0x01, 0x02, 0x03];
225        let result = bytes_to_usize(&payload);
226        assert!(result.is_err());
227        assert_eq!(
228            result.err().unwrap().to_string(),
229            "Not enough bytes to represent a `usize`"
230        );
231    }
232
233    #[rstest]
234    fn test_bytes_to_usize_valid() {
235        let payload: Vec<u8> = vec![0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
236        let result = bytes_to_usize(&payload).unwrap();
237        assert_eq!(result, 0x0807_0605_0403_0201);
238        assert_eq!(result, 578_437_695_752_307_201);
239    }
240
241    #[rstest]
242    fn test_precision_from_str_large_exponent_clamped() {
243        // u8::MAX is 255, so 999 should be clamped to 255
244        let result = precision_from_str("1e-999");
245        assert_eq!(result, 255);
246    }
247
248    #[rstest]
249    fn test_precision_from_str_very_large_exponent_clamped() {
250        // Very large exponents should also be clamped to u8::MAX
251        let result = precision_from_str("1e-300");
252        assert_eq!(result, 255);
253
254        let result = precision_from_str("1e-1000000");
255        assert_eq!(result, 255);
256    }
257
258    #[rstest]
259    #[should_panic(expected = "Invalid scientific notation exponent")]
260    fn test_precision_from_str_invalid_exponent_not_numeric() {
261        let _ = precision_from_str("1e-abc");
262    }
263
264    #[rstest]
265    #[should_panic(expected = "missing exponent after 'e-'")]
266    fn test_precision_from_str_malformed_scientific_notation() {
267        // "1e-" with empty exponent should panic (fail fast on malformed input)
268        let _ = precision_from_str("1e-");
269    }
270
271    #[rstest]
272    fn test_precision_from_str_edge_case_max_u8() {
273        // u8::MAX = 255, should work
274        let result = precision_from_str("1e-255");
275        assert_eq!(result, 255);
276    }
277
278    #[rstest]
279    fn test_precision_from_str_just_above_max_u8() {
280        // 256 should be clamped to 255
281        let result = precision_from_str("1e-256");
282        assert_eq!(result, 255);
283    }
284
285    #[rstest]
286    fn test_precision_from_str_u32_overflow() {
287        // Exponent > u32::MAX (4294967296) should be clamped to 255
288        let result = precision_from_str("1e-4294967296");
289        assert_eq!(result, 255);
290    }
291
292    #[rstest]
293    fn test_precision_from_str_u64_overflow() {
294        // Exponent > u64::MAX should be clamped to 255
295        let result = precision_from_str("1e-99999999999999999999");
296        assert_eq!(result, 255);
297    }
298
299    #[rstest]
300    fn test_min_increment_precision_from_str_large_exponent() {
301        // Large exponents should be clamped to u8::MAX (255), not return 0
302        let result = min_increment_precision_from_str("1e-300");
303        assert_eq!(result, 255);
304    }
305
306    #[rstest]
307    fn test_min_increment_precision_from_str_very_large_exponent() {
308        // Very large exponents should also be clamped to 255
309        let result = min_increment_precision_from_str("1e-99999999999999999999");
310        assert_eq!(result, 255);
311    }
312
313    #[rstest]
314    fn test_min_increment_precision_from_str_consistency() {
315        // Should match precision_from_str for large exponents
316        let input = "1e-1000";
317        let precision = precision_from_str(input);
318        let min_precision = min_increment_precision_from_str(input);
319        assert_eq!(precision, min_precision);
320        assert_eq!(precision, 255);
321    }
322
323    #[rstest]
324    fn test_min_increment_precision_from_str_empty_exponent() {
325        // Empty exponent should return 0, not u8::MAX
326        let result = min_increment_precision_from_str("1e-");
327        assert_eq!(result, 0);
328    }
329}