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/// Computes decimal precision from a scientific-notation string.
39///
40/// Precision is `max(0, mantissa_fractional_digits - exponent)`, clamped to
41/// `u8::MAX` (255). When `trim_trailing_zeros` is true, trailing zeros in the
42/// mantissa's fractional part are stripped before counting.
43///
44/// Absurd exponents that overflow `i64` clamp to 255 (negative) or 0 (positive)
45/// without panicking.
46///
47/// # Panics
48///
49/// Panics if `strict` is true and the exponent is missing or non-numeric.
50fn precision_from_scientific(
51    s: &str,
52    e_pos: usize,
53    trim_trailing_zeros: bool,
54    strict: bool,
55) -> Option<u8> {
56    let mantissa = &s[..e_pos];
57    let exponent_str = &s[e_pos + 1..];
58
59    let frac_digits = mantissa.split_once('.').map_or(0, |(_, frac)| {
60        if trim_trailing_zeros {
61            frac.trim_end_matches('0').len()
62        } else {
63            frac.len()
64        }
65    });
66
67    let exponent: i64 = if let Ok(v) = exponent_str.parse::<i64>() {
68        v
69    } else {
70        let (digits, is_negative) = exponent_str
71            .strip_prefix('-')
72            .map(|rest| (rest, true))
73            .or_else(|| exponent_str.strip_prefix('+').map(|rest| (rest, false)))
74            .unwrap_or((exponent_str, false));
75
76        if digits.is_empty() {
77            assert!(
78                !strict,
79                "Invalid scientific notation format: missing exponent value"
80            );
81            return None;
82        }
83
84        if digits.chars().all(|c| c.is_ascii_digit()) {
85            return Some(if is_negative { u8::MAX } else { 0 });
86        }
87
88        assert!(
89            !strict,
90            "Invalid scientific notation exponent '{exponent_str}': must be a valid number"
91        );
92        return None;
93    };
94
95    let precision = i64::try_from(frac_digits)
96        .unwrap_or(i64::MAX)
97        .saturating_sub(exponent)
98        .clamp(0, i64::from(u8::MAX));
99
100    #[expect(
101        clippy::cast_possible_truncation,
102        clippy::cast_sign_loss,
103        reason = "clamped to 0..=u8::MAX above"
104    )]
105    let precision = precision as u8;
106
107    Some(precision)
108}
109
110/// Returns the decimal precision inferred from the given string.
111///
112/// For scientific notation (e.g., "1e-300", "1.5e-2"), the precision accounts
113/// for both the mantissa's fractional digits and the signed exponent:
114/// `max(0, fractional_digits - exponent)`, clamped to `u8::MAX` (255).
115///
116/// # Panics
117///
118/// Panics if the input string is malformed (e.g., "1e-" with no exponent value, or non-numeric
119/// exponents like "1e-abc").
120#[must_use]
121pub fn precision_from_str(s: &str) -> u8 {
122    let s = s.trim();
123
124    if let Some(e_pos) = s.find(['e', 'E']) {
125        return precision_from_scientific(s, e_pos, false, true)
126            .expect("precision_from_scientific should return Some in strict mode");
127    }
128
129    if let Some((_, decimal_part)) = s.split_once('.') {
130        clamp_precision_with_log(decimal_part.len(), "Decimal", s)
131    } else {
132        0
133    }
134}
135
136/// Returns the minimum increment precision inferred from the given string,
137/// ignoring trailing zeros.
138///
139/// For scientific notation (e.g., "1e-300", "1.5e-2"), trailing zeros in the
140/// mantissa are stripped before computing precision, matching the behavior of
141/// [`precision_from_str`].
142#[must_use]
143pub fn min_increment_precision_from_str(s: &str) -> u8 {
144    let s = s.trim();
145
146    if let Some(e_pos) = s.find(['e', 'E']) {
147        return precision_from_scientific(s, e_pos, true, false).unwrap_or(0);
148    }
149
150    if let Some(dot_pos) = s.find('.') {
151        let decimal_part = &s[dot_pos + 1..];
152        match decimal_part.as_bytes().iter().rposition(|&b| b != b'0') {
153            Some(index) => clamp_precision_with_log(index + 1, "Minimum increment", s),
154            None => clamp_precision_with_log(decimal_part.len(), "Decimal", s),
155        }
156    } else {
157        0
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use rstest::rstest;
164
165    use super::*;
166
167    #[rstest]
168    #[case("", 0)]
169    #[case("0", 0)]
170    #[case("1.0", 1)]
171    #[case("1.00", 2)]
172    #[case("1.23456789", 8)]
173    #[case("123456.789101112", 9)]
174    #[case("0.000000001", 9)]
175    #[case("1e-1", 1)]
176    #[case("1e-2", 2)]
177    #[case("1e-3", 3)]
178    #[case("1e8", 0)]
179    #[case("-1.23", 2)]
180    #[case("-1e-2", 2)]
181    #[case("1E-2", 2)]
182    #[case("1.5e-2", 3)]
183    #[case("1.23e-2", 4)]
184    #[case("1.5e2", 0)]
185    #[case("1.5E2", 0)]
186    #[case("1.5e+2", 0)]
187    #[case("1.5e0", 1)]
188    #[case("1.5e-1", 2)]
189    #[case("  1.23", 2)]
190    #[case("1.23  ", 2)]
191    fn test_precision_from_str(#[case] s: &str, #[case] expected: u8) {
192        let result = precision_from_str(s);
193        assert_eq!(result, expected);
194    }
195
196    #[rstest]
197    #[case("", 0)]
198    #[case("0", 0)]
199    #[case("1.0", 1)]
200    #[case("1.00", 2)]
201    #[case("1.23456789", 8)]
202    #[case("123456.789101112", 9)]
203    #[case("0.000000001", 9)]
204    #[case("1e-1", 1)]
205    #[case("1e-2", 2)]
206    #[case("1e-3", 3)]
207    #[case("1e8", 0)]
208    #[case("-1.23", 2)]
209    #[case("-1e-2", 2)]
210    #[case("1E-2", 2)]
211    #[case("1.5e-2", 3)]
212    #[case("1.23e-2", 4)]
213    #[case("1.5e2", 0)]
214    #[case("1.50e-2", 3)]
215    #[case("1.0e-2", 2)]
216    #[case("  1.23", 2)]
217    #[case("1.23  ", 2)]
218    #[case("1.010", 2)]
219    #[case("1.00100", 3)]
220    #[case("0.0001000", 4)]
221    #[case("1.000000000", 9)]
222    fn test_min_increment_precision_from_str(#[case] s: &str, #[case] expected: u8) {
223        let result = min_increment_precision_from_str(s);
224        assert_eq!(result, expected);
225    }
226
227    #[rstest]
228    fn test_precision_from_str_large_exponent_clamped() {
229        // u8::MAX is 255, so 999 should be clamped to 255
230        let result = precision_from_str("1e-999");
231        assert_eq!(result, 255);
232    }
233
234    #[rstest]
235    fn test_precision_from_str_very_large_exponent_clamped() {
236        // Very large exponents should also be clamped to u8::MAX
237        let result = precision_from_str("1e-300");
238        assert_eq!(result, 255);
239
240        let result = precision_from_str("1e-1000000");
241        assert_eq!(result, 255);
242    }
243
244    #[rstest]
245    #[should_panic(expected = "Invalid scientific notation exponent")]
246    fn test_precision_from_str_invalid_exponent_not_numeric() {
247        let _ = precision_from_str("1e-abc");
248    }
249
250    #[rstest]
251    #[should_panic(expected = "missing exponent value")]
252    fn test_precision_from_str_malformed_scientific_notation() {
253        // "1e-" with empty exponent should panic (fail fast on malformed input)
254        let _ = precision_from_str("1e-");
255    }
256
257    #[rstest]
258    fn test_precision_from_str_edge_case_max_u8() {
259        // u8::MAX = 255, should work
260        let result = precision_from_str("1e-255");
261        assert_eq!(result, 255);
262    }
263
264    #[rstest]
265    fn test_precision_from_str_just_above_max_u8() {
266        // 256 should be clamped to 255
267        let result = precision_from_str("1e-256");
268        assert_eq!(result, 255);
269    }
270
271    #[rstest]
272    fn test_precision_from_str_u32_overflow() {
273        // Exponent > u32::MAX (4294967296) should be clamped to 255
274        let result = precision_from_str("1e-4294967296");
275        assert_eq!(result, 255);
276    }
277
278    #[rstest]
279    fn test_precision_from_str_u64_overflow() {
280        // Exponent > u64::MAX should be clamped to 255
281        let result = precision_from_str("1e-99999999999999999999");
282        assert_eq!(result, 255);
283    }
284
285    #[rstest]
286    fn test_min_increment_precision_from_str_large_exponent() {
287        // Large exponents should be clamped to u8::MAX (255), not return 0
288        let result = min_increment_precision_from_str("1e-300");
289        assert_eq!(result, 255);
290    }
291
292    #[rstest]
293    fn test_min_increment_precision_from_str_very_large_exponent() {
294        // Very large exponents should also be clamped to 255
295        let result = min_increment_precision_from_str("1e-99999999999999999999");
296        assert_eq!(result, 255);
297    }
298
299    #[rstest]
300    fn test_min_increment_precision_from_str_consistency() {
301        // Should match precision_from_str for large exponents
302        let input = "1e-1000";
303        let precision = precision_from_str(input);
304        let min_precision = min_increment_precision_from_str(input);
305        assert_eq!(precision, min_precision);
306        assert_eq!(precision, 255);
307    }
308
309    #[rstest]
310    fn test_precision_from_str_i64_min_exponent_clamped() {
311        // Exponent equal to i64::MIN must saturate, not overflow the subtraction
312        let result = precision_from_str("1e-9223372036854775808");
313        assert_eq!(result, 255);
314    }
315
316    #[rstest]
317    fn test_min_increment_precision_from_str_empty_exponent() {
318        // Empty exponent should return 0, not u8::MAX
319        let result = min_increment_precision_from_str("1e-");
320        assert_eq!(result, 0);
321    }
322
323    #[rstest]
324    #[case(
325        "1E-ABC",
326        "Invalid scientific notation exponent '-ABC': must be a valid number"
327    )]
328    #[case(
329        "1eE-2",
330        "Invalid scientific notation exponent 'E-2': must be a valid number"
331    )]
332    #[case("1E-", "Invalid scientific notation format: missing exponent value")]
333    fn test_scientific_precision_reports_original_exponent(
334        #[case] input: &str,
335        #[case] expected: &str,
336    ) {
337        assert_eq!(min_increment_precision_from_str(input), 0);
338        let panic = std::panic::catch_unwind(|| precision_from_str(input)).unwrap_err();
339        let message = panic
340            .downcast_ref::<String>()
341            .map(String::as_str)
342            .or_else(|| panic.downcast_ref::<&str>().copied())
343            .unwrap();
344        assert_eq!(message, expected);
345    }
346
347    #[rstest]
348    #[case("1.\u{e9}00", 2)]
349    #[case("1.\u{660}00", 2)]
350    #[case("1.00\u{660}", 4)]
351    #[case("1.", 0)]
352    fn test_minimum_precision_preserves_utf8_byte_length(
353        #[case] input: &str,
354        #[case] expected: u8,
355    ) {
356        assert_eq!(min_increment_precision_from_str(input), expected);
357    }
358
359    #[rstest]
360    fn test_minimum_precision_clamps_all_zero_fraction() {
361        let input = format!("1.{}", "0".repeat(256));
362        assert_eq!(min_increment_precision_from_str(&input), u8::MAX);
363    }
364}