Skip to main content

deep_time/t_span/
from_str.rs

1use crate::{
2    DtErr, DtErrKind, SEC_PER_DAY, SEC_PER_MONTH, SEC_PER_WEEK, SEC_PER_YEAR, TSpan, an_err,
3};
4
5struct ParsedComponent {
6    unit: u8,
7    signed_int: i64,
8    frac_digits: usize,
9    frac_num: i64,
10}
11
12impl TSpan {
13    pub fn from_iso(s: &str) -> Result<TSpan, DtErr> {
14        let len = s.len();
15        if len == 0 {
16            return Err(an_err!(DtErrKind::Incomplete, "empty"));
17        }
18
19        let b = s.as_bytes();
20        let mut i = 0usize;
21
22        // Optional leading sign (+ or -)
23        let mut sign: i64 = 1;
24        if i < len && matches!(b[i], b'+' | b'-') {
25            if b[i] == b'-' {
26                sign = -1;
27            }
28            i += 1;
29        }
30
31        // Must start with P/p
32        if i >= len || !matches!(b[i], b'P' | b'p') {
33            return Err(an_err!(DtErrKind::MustStartWith, "P"));
34        }
35        i += 1;
36
37        // Find the (single) T/t separator
38        let t_pos = b[i..]
39            .iter()
40            .position(|&c| matches!(c, b'T' | b't'))
41            .map(|p| i + p);
42
43        let (date_part, time_part) = match t_pos {
44            Some(pos) => {
45                if pos == len - 1 {
46                    return Err(an_err!(DtErrKind::InvalidSyntax, "T with no time"));
47                }
48                if b[pos + 1..].iter().any(|&c| matches!(c, b'T' | b't')) {
49                    return Err(an_err!(DtErrKind::InvalidSyntax, "multiple T"));
50                }
51                (&b[i..pos], &b[pos + 1..])
52            }
53            None => (&b[i..], &[] as &[u8]),
54        };
55
56        let mut has_fraction = false;
57        let mut total_nanos: i128 = 0;
58
59        // Both date and time parts now use the same fixed-length logic
60        Self::parse_duration_part(date_part, &mut total_nanos, true, sign, &mut has_fraction)?;
61        Self::parse_duration_part(time_part, &mut total_nanos, false, sign, &mut has_fraction)?;
62
63        // Convert accumulated nanoseconds to attoseconds and build TSpan
64        let total_attos = total_nanos * 1_000_000_000i128;
65        Ok(TSpan::from_attos(total_attos))
66    }
67
68    /// Parses a single component (number + optional fraction + unit) from the slice,
69    /// advancing the index `i`. Returns `None` when the slice is exhausted.
70    fn parse_next_component(
71        chars: &[u8],
72        i: &mut usize,
73        sign: i64,
74        has_fraction: &mut bool,
75    ) -> Result<Option<ParsedComponent>, DtErr> {
76        if *i >= chars.len() {
77            return Ok(None);
78        }
79
80        if *has_fraction {
81            return Err(an_err!(DtErrKind::InvalidSyntax, "components after frac"));
82        }
83
84        // Parse integer part
85        let start = *i;
86        while *i < chars.len() && chars[*i].is_ascii_digit() {
87            *i += 1;
88        }
89        if start == *i {
90            return Err(an_err!(DtErrKind::ExpectedValue, "number"));
91        }
92
93        let int_str = core::str::from_utf8(&chars[start..*i])
94            .map_err(|_| an_err!(DtErrKind::InvalidNumber, "invalid utf8 in int"))?;
95        let int: i64 = int_str.parse().map_err(|e: core::num::ParseIntError| {
96            an_err!(DtErrKind::InvalidNumber, "{}: {}", int_str, e)
97        })?;
98
99        // Parse optional fraction
100        let mut frac_num: i64 = 0;
101        let mut frac_digits: usize = 0;
102        if *i < chars.len() && matches!(chars[*i], b'.' | b',') {
103            *i += 1;
104            let frac_start = *i;
105            while *i < chars.len() && chars[*i].is_ascii_digit() {
106                *i += 1;
107            }
108            frac_digits = *i - frac_start;
109            if frac_digits == 0 {
110                return Err(an_err!(DtErrKind::ExpectedValue, "empty frac after ."));
111            }
112            if frac_digits > 9 {
113                return Err(an_err!(DtErrKind::OutOfRange, "frac >9"));
114            }
115
116            let frac_str = core::str::from_utf8(&chars[frac_start..*i])
117                .map_err(|_| an_err!(DtErrKind::InvalidNumber, "invalid utf8 in frac"))?;
118            frac_num = frac_str.parse().map_err(|e: core::num::ParseIntError| {
119                an_err!(DtErrKind::InvalidNumber, "{}: {}", frac_str, e)
120            })?;
121        }
122
123        // Unit must follow
124        if *i >= chars.len() {
125            return Err(an_err!(
126                DtErrKind::InvalidSyntax,
127                "missing unit after number"
128            ));
129        }
130        let unit = chars[*i];
131        *i += 1;
132
133        // Only seconds support a fractional part
134        if frac_digits > 0 {
135            if !matches!(unit, b'S' | b's') {
136                return Err(an_err!(
137                    DtErrKind::InvalidSyntax,
138                    "frac only supported for seconds"
139                ));
140            }
141            *has_fraction = true;
142        }
143
144        let signed_int = (int as i128 * sign as i128) as i64;
145
146        Ok(Some(ParsedComponent {
147            unit,
148            signed_int,
149            frac_digits,
150            frac_num,
151        }))
152    }
153
154    /// Helper that parses **one section** of an ISO duration (date or time part)
155    /// and accumulates nanoseconds into `total_nanos`.
156    ///
157    /// Years, months, weeks, and days are converted using the fixed-length
158    /// constants (the only sensible semantics for a pure `TSpan`).
159    fn parse_duration_part(
160        chars: &[u8],
161        total_nanos: &mut i128,
162        is_date: bool,
163        sign: i64,
164        has_fraction: &mut bool,
165    ) -> Result<(), DtErr> {
166        let mut i = 0;
167        while let Some(comp) = Self::parse_next_component(chars, &mut i, sign, has_fraction)? {
168            let contrib_nanos = match (is_date, comp.unit) {
169                (true, b'Y' | b'y') => {
170                    let total_secs = (comp.signed_int as i128)
171                        .checked_mul(SEC_PER_YEAR)
172                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "year"))?;
173                    total_secs * 1_000_000_000i128
174                }
175                (true, b'M' | b'm') => {
176                    let total_secs = (comp.signed_int as i128)
177                        .checked_mul(SEC_PER_MONTH)
178                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "month"))?;
179                    total_secs * 1_000_000_000i128
180                }
181                (true, b'W' | b'w') => {
182                    let total_secs = (comp.signed_int as i128)
183                        .checked_mul(SEC_PER_WEEK as i128)
184                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "week"))?;
185                    total_secs * 1_000_000_000i128
186                }
187                (true, b'D' | b'd') => {
188                    let total_secs = (comp.signed_int as i128)
189                        .checked_mul(SEC_PER_DAY)
190                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "day"))?;
191                    total_secs * 1_000_000_000i128
192                }
193                (false, b'H' | b'h') => (comp.signed_int as i128) * 3_600_000_000_000i128,
194                (false, b'M' | b'm') => (comp.signed_int as i128) * 60_000_000_000i128,
195                (false, b'S' | b's') => {
196                    let mut sec_nanos = (comp.signed_int as i128) * 1_000_000_000i128;
197                    if comp.frac_digits > 0 {
198                        let frac_ns = (comp.frac_num as i128 * sign as i128 * 1_000_000_000i128)
199                            / 10i128.pow(comp.frac_digits as u32);
200                        sec_nanos += frac_ns;
201                    }
202                    sec_nanos
203                }
204                _ => {
205                    return Err(an_err!(DtErrKind::InvalidItem, "{}", comp.unit as char));
206                }
207            };
208
209            *total_nanos = total_nanos.saturating_add(contrib_nanos);
210        }
211        Ok(())
212    }
213
214    /// Accepts: `P1Y`, `-P2W`, `PT1.5H`, `P1DT2H30M`, `+P3D`, `p1y`, `P1,5S`, `PT0S`, etc.
215    /// Rejects: anything with whitespace, lone "P"/"-P"/"PT", "P123", "Please wait 5m",
216    ///          "1.5h", "P1Yabc", "P1Y!", or **any string longer than 128 bytes**.
217    pub fn looks_like_iso(s: &str) -> bool {
218        let len = s.len();
219        if matches!(len, 0 | 1) {
220            return false;
221        }
222        let b = s.as_bytes();
223        let mut i = 0usize;
224        // Optional leading sign
225        if matches!(b[0], b'+' | b'-') {
226            i += 1;
227        }
228        // Must start with P/p after optional sign
229        if !matches!(b[i], b'P' | b'p') {
230            return false;
231        }
232        i += 1;
233        let mut has_digit = false;
234        let mut has_designator = false;
235        while i < len {
236            match b[i] {
237                b'0'..=b'9' => has_digit = true,
238                b'.' | b',' => {} // decimal separators allowed by ISO 8601
239                b'Y' | b'y' | b'M' | b'm' | b'W' | b'w' | b'D' | b'd' | b'T' | b't' | b'H'
240                | b'h' | b'S' | b's' => {
241                    has_designator = true;
242                }
243                _ => return false, // any other character = not ISO
244            }
245
246            i += 1;
247        }
248        // Must contain at least one digit *and* one designator after the initial P
249        has_digit && has_designator
250    }
251}