1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
use crate::{
ATTOS_PER_SEC_I128, DtErr, DtErrKind, Epoch, Parser, Parts, STRTIME_SIZE, Scale, SecF,
Timestamp, an_err,
};
impl Parts {
/// Parser equivalent to `strptime` with a provided format string.
///
/// The parser populates a [`Parts`] struct. After successful parsing,
/// [`Self::finish`] is called automatically to apply defaults and validation.
///
/// ## Parameters
///
/// - `fmt`: The format string containing `%` directives.
/// - `input`: The string to parse.
/// - `inp_can_end_before_fmt`: If `true`, the input may end before the format
/// string is fully consumed (extra format specifiers are ignored).
/// - `fmt_can_end_before_inp`: If `true`, the format may end before the input
/// is fully consumed (trailing characters in the input are allowed).
/// - `allow_partial_date`: If `true`, a missing month/day will be defaulted
/// to `1` instead of returning an [`Incomplete`] error.
///
/// ## Supported Directives
///
/// The format string supports literal characters and the following `%` directives.
/// Literal non-whitespace characters must match the input exactly.
/// Whitespace in the format matches (and consumes) any leading ASCII whitespace in the input.
///
/// Many directives accept **format extensions** right after `%`:
/// - **Flags**: `-` (no pad), `_` (space pad), `0` (zero pad), `^`/`#` (treated as default)
/// - **Width**: 1–3 digits (affects numeric field width / padding expectations)
/// - **Colons** (only for `%z`): `:`, `::`, `:::` to control offset format
///
/// ### Year / Century / Unbounded
/// - `%Y` — Four-digit year (e.g. `2024`). Supports sign, flags, and width.
/// - `%y` — Two-digit year (`00`–`99`; `00`–`68` → 2000+, `69`–`99` → 1900s).
/// - `%C` — Century (`00`–`99`).
/// - `%G` — Four-digit ISO week-based year.
/// - `%g` — Two-digit ISO week-based year (same century rule as `%y`).
/// - `%*` — **Unbounded year** (arbitrary length, supports negative years). *Library extension.*
///
/// ### Month
/// - `%m` — Month number `01`–`12`.
/// - `%B` — Full English month name (e.g. `January`).
/// - `%b`, `%h` — Abbreviated English month name (3 letters, e.g. `Jan`).
///
/// ### Day
/// - `%d`, `%e` — Day of month `01`–`31` (`%e` allows space padding).
/// - `%j` — Day of year `001`–`366`.
///
/// ### Time of day
/// - `%H`, `%k` — Hour `00`–`23` (24-hour clock; `%k` allows space padding).
/// - `%I`, `%l` — Hour `01`–`12` (12-hour clock).
/// - `%M` — Minute `00`–`59`.
/// - `%S` — Second `00`–`60` (leap second allowed).
/// - `%f`, `%N` — Fractional seconds (up to 18 digits = attoseconds).
/// Width controls precision (`%3f` = ms, `%6N` = µs, `%9f` = ns, etc.).
/// Both accept an optional leading `.` in the input.
/// - `%.f`, `%.N`, `%.3f`, `%.6N`, ... — Same fractional parsing, but the
/// dot before the fraction is **optional** in the input (consumes literal `.` if present).
/// - `%P`, `%p` — `AM`/`PM` indicator (case-insensitive).
///
/// ### Weekday / Week number
/// - `%A` — Full English weekday name (e.g. `Monday`).
/// - `%a` — Abbreviated English weekday name (3 letters, e.g. `Mon`).
/// - `%u` — Weekday number Monday=`1` … Sunday=`7`.
/// - `%w` — Weekday number Sunday=`0` … Saturday=`6`.
/// - `%U` — Week number (Sunday-first week), `00`–`53`.
/// - `%W` — Week number (Monday-first week), `00`–`53`.
/// - `%V` — ISO 8601 week number `01`–`53`.
///
/// ### Timezone, Offset & Scale
/// - `%z` — Timezone offset. Colon count selects format:
/// - `%z` → `±HH[MM[SS]]` (minutes/seconds optional)
/// - `%:z` → `±HH:MM` (minutes required)
/// - `%::z` → `±HH:MM:SS` (seconds optional)
/// - `%:::z` → `±HH:MM:SS` (more flexible)
/// - `%Q` — IANA timezone name (e.g. `America/New_York`) **or** numeric offset
/// (if input starts with `+`/`-`). *Library extension.*
/// - `%L` — Time scale abbreviation (e.g. `TAI`, `UTC`, `GPS`). See [`Scale`].
/// *Library extension.*
///
/// ### Shortcuts (compound directives)
/// - `%F` — Equivalent to `%Y-%m-%d` (ISO date).
/// - `%D` — Equivalent to `%m/%d/%y` (US date).
/// - `%T` — Equivalent to `%H:%M:%S`.
/// - `%R` — Equivalent to `%H:%M`.
///
/// ### Other
/// - `%%` — Literal `%` character.
/// - `%s` — Unix timestamp (seconds since 1970-01-01 00:00 UTC, can be negative).
/// This directive greedily consumes any fractional seconds.
/// - `%J` — Seconds since 2000-01-01 12:00 TAI (J2000.0 noon epoch), can be negative.
/// This directive greedily consumes any fractional seconds.
/// - `%n`, `%t` — Any whitespace (consumes it from input).
///
/// ### Unsupported / Unknown
/// - `%c`, `%r`, `%x`, `%X`, `%Z` → [`DtErrKind::UnsupportedItem`]
/// - Any other unknown directive character → [`DtErrKind::UnknownItem`]
///
/// ## Errors
///
/// Returns [`DtErr`] containing one of the following [`DtErrKind`] variants:
///
/// ### Format string errors
///
/// - [`DtErrKind::TruncatedDirective`] — The format string ended immediately
/// after a `%`, after a `.` (in a fractional directive), or after flags/width/colons
/// with no directive character following (e.g. `%.`, `%_`, `%3`).
/// - [`DtErrKind::UnknownItem`] — Unknown `%` directive character.
/// - [`DtErrKind::UnsupportedItem`] — Known but unsupported directive
/// (e.g. `%c`, `%r`, `%x`, `%X`, `%Z`).
/// - [`DtErrKind::BadFractional`] — Malformed fractional directive
/// (e.g. `%.x` where `x` is not `f` or `N`).
///
/// ### Input parsing errors
///
/// - [`DtErrKind::UnexpectedInputEnd`] — Input ended before a required value
/// could be parsed.
/// - `Expected*` variants:
/// - [`DtErrKind::ExpectedYear`]
/// - [`DtErrKind::ExpectedCentury`]
/// - [`DtErrKind::ExpectedMonth`]
/// - [`DtErrKind::ExpectedDay`]
/// - [`DtErrKind::ExpectedDayOfYear`]
/// - [`DtErrKind::ExpectedHour`]
/// - [`DtErrKind::ExpectedMinute`]
/// - [`DtErrKind::ExpectedSecond`]
/// - [`DtErrKind::ExpectedFractionalSeconds`]
/// - [`DtErrKind::ExpectedTimestamp`]
/// - [`DtErrKind::ExpectedWeekNumber`]
/// - [`DtErrKind::ExpectedWeekdayNumber`]
/// - [`DtErrKind::MismatchedLiteral`] — A literal character from the format
/// string did not match the input.
/// - [`DtErrKind::OutOfRange`] — A numeric value was parsed but is outside
/// the valid range for that component (e.g. month 13, hour 25, day 32).
/// - [`DtErrKind::InvalidName`] — Unrecognized month name, weekday name,
/// or `am`/`pm` value.
/// - [`DtErrKind::InvalidTimezoneOffset`] — Invalid or malformed timezone
/// offset / IANA name.
/// - [`DtErrKind::MustStartWith`] — Timezone offset did not start with
/// `+` or `-`.
///
/// ### Post-processing / validation errors
///
/// - [`DtErrKind::Incomplete`] — Required date components (month/day) were
/// missing and `allow_partial_date` was `false`.
/// - [`DtErrKind::TrailingCharacters`] — The input contained trailing
/// characters after parsing and `fmt_can_end_before_inp` was `false`.
///
/// Because [`DtErrKind`] is `#[non_exhaustive]`, additional variants may
/// appear in the future. You can match on the variants you care about and
/// use a wildcard arm for the rest.
///
/// The concrete error kind is available via [`DtErr::kind()`] (or by
/// iterating [`DtErr::trace()`] if the error was chained with context
/// higher up the call stack).
pub fn from_str(
fmt: &str,
input: &str,
inp_can_end_before_fmt: bool,
fmt_can_end_before_inp: bool,
allow_partial_date: bool,
) -> Result<Parts, DtErr> {
let mut parts = Parts::new_utc();
let mut parser = Parser::new(
fmt.as_bytes(),
input.as_bytes(),
&mut parts,
inp_can_end_before_fmt,
);
parser.parse()?;
if parser.inp.is_empty() || fmt_can_end_before_inp {
// All input consumed → finalize
parts.finish(allow_partial_date)?;
Ok(parts)
} else {
// Trailing characters remain
Err(an_err!(DtErrKind::TrailingCharacters))
}
}
/// Finalizes a [`Parts`] after parsing by applying sensible defaults and
/// performing validation.
///
/// This is called automatically by the various parsing paths (`from_str`,
/// CCSDS parsers, etc.). It ensures the struct is in a consistent state
/// before being turned into a full [`Dt`] or passed to other converters.
///
/// ## Behavior
///
/// - If a Unix timestamp is present then no action is taken.
/// - Date completeness is checked in this priority order:
/// 1. Calendar date (`year`, `month`, `day`)
/// 2. Ordinal date (`year`, `day_of_year`)
/// 3. ISO week date (`iso_week_year`, `iso_week`)
/// - If `allow_partial_date` is `true`, missing month/day are defaulted to `1`.
///
/// ## Errors
///
/// - [`DtErrKind::Incomplete`] if no valid date representation is present.
#[inline(always)]
pub fn finish(&mut self, allow_partial_date: bool) -> Result<(), DtErr> {
if self.timestamp.is_none() {
let has_calendar_date = if allow_partial_date {
if self.day.is_none() {
self.day = Some(1);
}
if self.mo.is_none() {
self.mo = Some(1);
}
self.yr.is_some()
} else {
self.yr.is_some() && self.mo.is_some() && self.day.is_some()
};
let has_ordinal_date = self.yr.is_some() && self.day_of_yr.is_some();
let has_iso_week_date = self.iso_wk_yr.is_some() && self.iso_wk.is_some();
if !has_calendar_date && !has_ordinal_date && !has_iso_week_date {
return Err(an_err!(DtErrKind::Incomplete));
}
}
Ok(())
}
#[inline]
pub(crate) fn parse_sec_f(s: &str, scale: Option<Scale>) -> Option<SecF> {
let bytes = s.as_bytes();
if bytes.is_empty() || bytes.len() > STRTIME_SIZE {
return None;
}
// Skip leading junk until we see +, -, ., or a digit.
let mut pos = 0usize;
while pos < bytes.len() {
match bytes[pos] {
b'+' | b'-' | b'.' | b'0'..=b'9' => break,
_ => pos += 1,
}
}
if pos >= bytes.len() {
return None;
}
// Optional sign (only at the start of the number we decided to parse)
let negative = match bytes[pos] {
b'-' => {
pos += 1;
true
}
b'+' => {
pos += 1;
false
}
_ => false,
};
if pos >= bytes.len() {
return None;
}
// Integer part (may be empty when we landed on '.')
let mut int_u: u64 = 0;
let mut saw_digit = false;
while pos < bytes.len() && bytes[pos].is_ascii_digit() {
saw_digit = true;
let d = (bytes[pos] - b'0') as u64;
if int_u > u64::MAX / 10 {
int_u = u64::MAX;
pos += 1;
while pos < bytes.len() && bytes[pos].is_ascii_digit() {
pos += 1;
}
break;
} else {
int_u = int_u * 10 + d;
pos += 1;
}
}
// Optional fractional part
let mut frac_attos: u64 = 0;
let mut frac_digits: usize = 0;
if pos < bytes.len() && bytes[pos] == b'.' {
pos += 1;
while pos < bytes.len() && bytes[pos].is_ascii_digit() && frac_digits < 18 {
saw_digit = true;
let d = (bytes[pos] - b'0') as u64;
frac_attos = frac_attos * 10 + d;
frac_digits += 1;
pos += 1;
}
}
if !saw_digit {
return None;
}
let scl = match scale {
Some(s) => s,
None => Parts::parse_scale(&bytes[pos..]).unwrap_or_default(),
};
// Left-pad the fractional attos value to 18 digits total
if frac_digits > 0 {
let shift = 18 - frac_digits;
frac_attos *= 10u64.pow(shift as u32);
}
Some(SecF {
negative,
int_u,
frac_attos,
scale: scl,
})
}
/// Parses a decimal seconds string (with optional fractional part) as seconds
/// since [`Dt::ZERO`](../struct.Dt.html#associatedconstant.ZERO)
/// and returns a [`Parts`] that represents the same instant.
///
/// This is the [`Parts`] equivalent of
/// [`Dt::from_str_sec_f`](crate::Dt::from_str_sec_f).
///
/// - If `scale` is `Some(s)`, the value is interpreted on scale `s`.
/// - If `scale` is `None`, a trailing scale abbreviation (e.g. `GPS`, `TAI`,
/// `UTC`) is parsed from the input. If none is found, `TAI` is used.
///
/// Leading non-numeric characters are skipped until a number start is found
/// (`+`, `-`, `.`, or digit).
///
/// - Fractional seconds are limited to the first 18 digits (attosecond
/// precision); extra digits are truncated.
/// - Oversized integer parts saturate to the limits of `i64` (because
/// [`Parts`] stores the offset via [`TimestampSec::Noon2000`]).
/// - Inputs longer than [`STRTIME_SIZE`] are rejected.
/// - Returns `None` only for completely unparseable input.
///
/// The returned [`Parts`] has its `timestamp_sec` set to a `Noon2000` value
/// (seconds since the library epoch) plus the fractional `attos`. Calling
/// [`.to_dt()`](Self::to_dt) on it produces the equivalent instant.
///
/// ## Examples
///
/// ```rust
/// use deep_time::{Scale, civil_parts::Parts};
///
/// let p = Parts::from_str_sec_f("1700000000.123456789012345678", Some(Scale::TAI)).unwrap();
/// let dt = p.to_dt().unwrap();
/// assert_eq!(dt.to_sec64(), 1700000000);
///
/// // Trailing scale is recognized when scale arg is None
/// let p = Parts::from_str_sec_f("42.75 GPS", None).unwrap();
/// assert_eq!(p.scale, Scale::GPS);
/// ```
/// Shared parser for decimal "seconds + optional fraction" input.
///
/// Used by both [`Parts::from_str_sec_f`] and [`Dt::from_str_sec_f`].
/// Returns the raw numeric components + resolved scale; the caller decides
/// how to materialize the value (full attos for `Dt`, or Noon2000 timestamp
/// for `Parts`).
pub fn from_str_sec_f(s: &str, scale: Option<Scale>) -> Option<Parts> {
let parsed = Self::parse_sec_f(s, scale)?;
// Combine integer seconds + fractional attoseconds into one i128 value.
// This replaces the old TimestampSec + separate attos split.
let int_attos = (parsed.int_u as i128) * ATTOS_PER_SEC_I128;
let frac_attos = parsed.frac_attos as i128;
let total_attos = if parsed.negative {
-(int_attos + frac_attos)
} else {
int_attos + frac_attos
};
let parts = Parts {
timestamp: Some(Timestamp {
attos: total_attos,
epoch: Epoch::Noon2000,
}),
scale: parsed.scale,
..Default::default()
};
Some(parts)
}
}