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