Skip to main content

teksilo_widgets/common/datetime/
pattern.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Strftime-subset pattern parser shared by `DateEdit` and `TimeEdit`.
5//!
6//! Supported tokens:
7//!
8//! | Token | Meaning |
9//! | --- | --- |
10//! | `%Y` | 4-digit year (zero-padded; sign printed for negatives) |
11//! | `%m` / `%-m` | 2-digit / 1-or-2-digit month |
12//! | `%d` / `%-d` | 2-digit / 1-or-2-digit day |
13//! | `%H` / `%-H` | 24-hour hour |
14//! | `%I` / `%-I` | 12-hour hour |
15//! | `%M` / `%-M` | minute |
16//! | `%S` / `%-S` | second |
17//! | `%p` | AM/PM (12-hour mode) |
18//! | `%%` | literal `%` |
19//!
20//! Anything else between tokens is preserved verbatim as a literal
21//! separator. Locale-localized literal text (`%B` / `%A`) is deliberately
22//! NOT supported here — month and weekday *names* come from `tr!` keys,
23//! not from the pattern.
24
25use jiff::civil::{Date, DateTime, Time};
26
27/// One token in a parsed pattern.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum PatternToken {
30    /// Editable segment.
31    Segment(SegmentKind),
32    /// Verbatim text between segments.
33    Literal(String),
34}
35
36/// What a segment edits.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum SegmentKind {
39    /// Year, 4 digits.
40    Year,
41    /// Month, 2 digits, zero-padded.
42    Month,
43    /// Month, 1-or-2 digits, no padding.
44    MonthShort,
45    /// Day of month, 2 digits, zero-padded.
46    Day,
47    /// Day of month, 1-or-2 digits, no padding.
48    DayShort,
49    /// Hour, 24-hour clock, 2 digits.
50    Hour24,
51    /// Hour, 24-hour clock, 1-or-2 digits.
52    Hour24Short,
53    /// Hour, 12-hour clock, 2 digits.
54    Hour12,
55    /// Hour, 12-hour clock, 1-or-2 digits.
56    Hour12Short,
57    /// Minute, 2 digits.
58    Minute,
59    /// Minute, 1-or-2 digits.
60    MinuteShort,
61    /// Second, 2 digits.
62    Second,
63    /// Second, 1-or-2 digits.
64    SecondShort,
65    /// AM/PM marker.
66    Period,
67}
68
69impl SegmentKind {
70    /// Number of digit characters this segment renders. Period is non-numeric.
71    pub fn max_digits(self) -> usize {
72        match self {
73            Self::Year => 4,
74            Self::Month | Self::Day | Self::Hour24 | Self::Hour12 | Self::Minute | Self::Second => {
75                2
76            }
77            Self::MonthShort
78            | Self::DayShort
79            | Self::Hour24Short
80            | Self::Hour12Short
81            | Self::MinuteShort
82            | Self::SecondShort => 2,
83            Self::Period => 0,
84        }
85    }
86
87    /// Inclusive numeric range valid for this segment, ignoring
88    /// month-length variation (`Day` is `1..=31`).
89    pub fn value_range(self) -> Option<(i32, i32)> {
90        match self {
91            Self::Year => Some((-9999, 9999)),
92            Self::Month | Self::MonthShort => Some((1, 12)),
93            Self::Day | Self::DayShort => Some((1, 31)),
94            Self::Hour24 | Self::Hour24Short => Some((0, 23)),
95            Self::Hour12 | Self::Hour12Short => Some((1, 12)),
96            Self::Minute | Self::MinuteShort | Self::Second | Self::SecondShort => Some((0, 59)),
97            Self::Period => None,
98        }
99    }
100}
101
102/// A pattern parsed into segments + literals, ready for formatting and
103/// reverse parsing.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ParsedPattern {
106    pub tokens: Vec<PatternToken>,
107}
108
109impl ParsedPattern {
110    /// Parse a strftime-subset pattern.
111    pub fn parse(pattern: &str) -> Result<Self, PatternError> {
112        let mut tokens = Vec::new();
113        let mut literal = String::new();
114        let mut chars = pattern.chars().peekable();
115
116        let flush_literal = |lit: &mut String, tokens: &mut Vec<PatternToken>| {
117            if !lit.is_empty() {
118                tokens.push(PatternToken::Literal(std::mem::take(lit)));
119            }
120        };
121
122        while let Some(c) = chars.next() {
123            if c != '%' {
124                literal.push(c);
125                continue;
126            }
127            // Peek for `-` modifier (no padding) or directive char.
128            let no_pad = matches!(chars.peek(), Some('-'));
129            if no_pad {
130                chars.next();
131            }
132            let Some(d) = chars.next() else {
133                return Err(PatternError::TrailingPercent);
134            };
135            let segment = match (no_pad, d) {
136                (false, 'Y') => SegmentKind::Year,
137                (true, 'Y') => SegmentKind::Year,
138                (false, 'm') => SegmentKind::Month,
139                (true, 'm') => SegmentKind::MonthShort,
140                (false, 'd') => SegmentKind::Day,
141                (true, 'd') => SegmentKind::DayShort,
142                (false, 'H') => SegmentKind::Hour24,
143                (true, 'H') => SegmentKind::Hour24Short,
144                (false, 'I') => SegmentKind::Hour12,
145                (true, 'I') => SegmentKind::Hour12Short,
146                (false, 'M') => SegmentKind::Minute,
147                (true, 'M') => SegmentKind::MinuteShort,
148                (false, 'S') => SegmentKind::Second,
149                (true, 'S') => SegmentKind::SecondShort,
150                (false, 'p') => SegmentKind::Period,
151                (false, '%') => {
152                    literal.push('%');
153                    continue;
154                }
155                (no_pad, ch) => {
156                    return Err(PatternError::UnsupportedDirective {
157                        directive: ch,
158                        with_dash_modifier: no_pad,
159                    });
160                }
161            };
162            flush_literal(&mut literal, &mut tokens);
163            tokens.push(PatternToken::Segment(segment));
164        }
165        flush_literal(&mut literal, &mut tokens);
166        Ok(Self { tokens })
167    }
168
169    /// Iterator over only the segment tokens, in document order.
170    pub fn segments(&self) -> impl Iterator<Item = SegmentKind> + '_ {
171        self.tokens.iter().filter_map(|t| match t {
172            PatternToken::Segment(k) => Some(*k),
173            _ => None,
174        })
175    }
176}
177
178/// Pattern parse errors.
179#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
180pub enum PatternError {
181    /// `%` at end of string.
182    #[error("trailing `%` at end of pattern")]
183    TrailingPercent,
184    /// `%X` where `X` isn't a supported directive.
185    #[error(
186        "{}",
187        if *with_dash_modifier {
188            format!("unsupported directive `%-{directive}` in pattern")
189        } else {
190            format!("unsupported directive `%{directive}` in pattern")
191        }
192    )]
193    UnsupportedDirective {
194        directive: char,
195        with_dash_modifier: bool,
196    },
197}
198
199/// What a segment evaluates to. `Year` can be negative; the rest
200/// are non-negative integers. `Period` is `0` for AM, `1` for PM.
201pub fn segment_value_for_date(d: Date, kind: SegmentKind) -> Option<i32> {
202    Some(match kind {
203        SegmentKind::Year => d.year() as i32,
204        SegmentKind::Month | SegmentKind::MonthShort => d.month() as i32,
205        SegmentKind::Day | SegmentKind::DayShort => d.day() as i32,
206        _ => return None,
207    })
208}
209
210/// Time-half evaluator.
211pub fn segment_value_for_time(t: Time, kind: SegmentKind) -> Option<i32> {
212    Some(match kind {
213        SegmentKind::Hour24 | SegmentKind::Hour24Short => t.hour() as i32,
214        SegmentKind::Hour12 | SegmentKind::Hour12Short => {
215            let h = t.hour() as i32;
216            let h12 = h % 12;
217            if h12 == 0 { 12 } else { h12 }
218        }
219        SegmentKind::Minute | SegmentKind::MinuteShort => t.minute() as i32,
220        SegmentKind::Second | SegmentKind::SecondShort => t.second() as i32,
221        SegmentKind::Period => {
222            if t.hour() < 12 {
223                0
224            } else {
225                1
226            }
227        }
228        _ => return None,
229    })
230}
231
232/// Format a value into its segment string.
233pub fn render_segment(kind: SegmentKind, value: i32) -> String {
234    match kind {
235        SegmentKind::Year => {
236            if value < 0 {
237                format!("-{:04}", value.unsigned_abs())
238            } else {
239                format!("{:04}", value)
240            }
241        }
242        SegmentKind::Month
243        | SegmentKind::Day
244        | SegmentKind::Hour24
245        | SegmentKind::Hour12
246        | SegmentKind::Minute
247        | SegmentKind::Second => format!("{:02}", value),
248        SegmentKind::MonthShort
249        | SegmentKind::DayShort
250        | SegmentKind::Hour24Short
251        | SegmentKind::Hour12Short
252        | SegmentKind::MinuteShort
253        | SegmentKind::SecondShort => format!("{}", value),
254        SegmentKind::Period => {
255            if value == 0 {
256                "AM".to_string()
257            } else {
258                "PM".to_string()
259            }
260        }
261    }
262}
263
264/// Format a `Date` against a parsed pattern. Time segments emit "00".
265pub fn format_value(pattern: &ParsedPattern, date: Option<Date>, time: Option<Time>) -> String {
266    let mut out = String::new();
267    for token in &pattern.tokens {
268        match token {
269            PatternToken::Literal(s) => out.push_str(s),
270            PatternToken::Segment(kind) => {
271                let value = match (date, time) {
272                    (Some(d), _) if segment_value_for_date(d, *kind).is_some() => {
273                        segment_value_for_date(d, *kind).expect("guarded by is_some() above")
274                    }
275                    (_, Some(t)) => segment_value_for_time(t, *kind).unwrap_or(0),
276                    (Some(d), None) => segment_value_for_date(d, *kind).unwrap_or(0),
277                    (None, None) => 0,
278                };
279                out.push_str(&render_segment(*kind, value));
280            }
281        }
282    }
283    out
284}
285
286/// What kind of value is being parsed.
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum ParseTarget {
289    DateOnly,
290    TimeOnly,
291    DateTime,
292}
293
294/// Parsed value.
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum ParsedValue {
297    Date(Date),
298    Time(Time),
299    DateTime(DateTime),
300}
301
302/// Reverse-parse a string against a pattern.
303///
304/// Strict about literal separators but **lenient about trailing
305/// segments**: input that runs out partway through the pattern fills
306/// the remaining segments with sensible defaults (month → `1`, day →
307/// `1`, hour/minute/second → `0`, AM/PM → AM). The first segment of
308/// each target *is* required:
309///
310/// - `DateOnly` / `DateTime`: the year must be present.
311/// - `TimeOnly`: the hour must be present.
312///
313/// Examples for pattern `%Y-%m-%d`:
314///
315/// | Input | Result |
316/// | --- | --- |
317/// | `"2026-05-02"` | `Date(2026, 5, 2)` |
318/// | `"2026-5-2"` | `Date(2026, 5, 2)` (1-digit segments) |
319/// | `"2026-5"` | `Date(2026, 5, 1)` (day defaulted) |
320/// | `"2026-"` | `Date(2026, 1, 1)` (month + day defaulted) |
321/// | `"2026"` | `Date(2026, 1, 1)` |
322/// | `""` | `None` (year required) |
323/// | `"2026/05/02"` | `None` (literal separator mismatch) |
324/// | `"2026-13-02"` | `None` (out of range) |
325///
326/// Out-of-range values and literal-separator mismatches always
327/// reject — leniency only applies to *missing* trailing input.
328pub fn parse_value(
329    pattern: &ParsedPattern,
330    input: &str,
331    target: ParseTarget,
332) -> Option<ParsedValue> {
333    let mut cursor = input;
334    let mut year: Option<i16> = None;
335    let mut month: Option<i8> = None;
336    let mut day: Option<i8> = None;
337    let mut hour24: Option<i8> = None;
338    let mut hour12: Option<i8> = None;
339    let mut minute: Option<i8> = None;
340    let mut second: Option<i8> = None;
341    let mut period: Option<i8> = None; // 0 = AM, 1 = PM
342
343    for token in &pattern.tokens {
344        // End-of-input mid-pattern: stop consuming, let the
345        // defaults fill in the rest. Empty cursor that lands on a
346        // literal token is fine — we just skip the literal. Empty
347        // cursor on a segment token is fine — that segment stays
348        // None and the per-target resolver below applies its
349        // default (month=1, day=1, ...).
350        if cursor.is_empty() {
351            break;
352        }
353        match token {
354            PatternToken::Literal(lit) => {
355                // Lenient: accept a partial literal at end-of-input
356                // (e.g. user typed "2026-" with pattern "%Y-%m-%d").
357                // If the literal is the start of cursor we consume
358                // it; if cursor is shorter than literal AND is a
359                // prefix of literal, we accept and stop.
360                if let Some(trimmed) = cursor.strip_prefix(lit.as_str()) {
361                    cursor = trimmed;
362                } else if lit.starts_with(cursor) {
363                    // Cursor is a strict prefix of the expected
364                    // literal — treat as "user stopped typing
365                    // mid-separator", consume what's there.
366                    cursor = "";
367                } else {
368                    // Literal mismatch (e.g. "/" where "-" was
369                    // expected). Always reject.
370                    return None;
371                }
372            }
373            PatternToken::Segment(kind) => {
374                if matches!(kind, SegmentKind::Period) {
375                    // Period needs at least 1 char to pick AM vs
376                    // PM. Be tolerant of a single 'A'/'P' (user
377                    // mid-typing). Lower-case accepted.
378                    let first_char = cursor.chars().next()?;
379                    let upper = first_char.to_ascii_uppercase();
380                    match upper {
381                        'A' => {
382                            period = Some(0);
383                            cursor = consume_period_letters(cursor);
384                        }
385                        'P' => {
386                            period = Some(1);
387                            cursor = consume_period_letters(cursor);
388                        }
389                        _ => return None,
390                    }
391                    continue;
392                }
393                let max = kind.max_digits();
394                let Some((digits, rest)) = take_digits(cursor, max) else {
395                    // No digits at cursor — treat as missing
396                    // segment, stop here. Subsequent tokens get
397                    // defaults via the resolver below.
398                    break;
399                };
400                cursor = rest;
401                let v: i32 = digits.parse().ok()?;
402                let (lo, hi) = kind.value_range().unwrap_or((i32::MIN, i32::MAX));
403                if v < lo || v > hi {
404                    return None;
405                }
406                match kind {
407                    SegmentKind::Year => year = Some(v as i16),
408                    SegmentKind::Month | SegmentKind::MonthShort => month = Some(v as i8),
409                    SegmentKind::Day | SegmentKind::DayShort => day = Some(v as i8),
410                    SegmentKind::Hour24 | SegmentKind::Hour24Short => hour24 = Some(v as i8),
411                    SegmentKind::Hour12 | SegmentKind::Hour12Short => hour12 = Some(v as i8),
412                    SegmentKind::Minute | SegmentKind::MinuteShort => minute = Some(v as i8),
413                    SegmentKind::Second | SegmentKind::SecondShort => second = Some(v as i8),
414                    SegmentKind::Period => unreachable!(),
415                }
416            }
417        }
418    }
419    // Trailing whitespace after consumption is OK; non-whitespace
420    // junk is a parse error.
421    if !cursor.trim().is_empty() {
422        return None;
423    }
424
425    // Resolve hour from 12h + AM/PM if 24h not present.
426    let hour = match (hour24, hour12, period) {
427        (Some(h), _, _) => h,
428        (None, Some(h12), Some(p)) => {
429            let base = h12 % 12;
430            base + if p == 1 { 12 } else { 0 }
431        }
432        (None, Some(h12), None) => h12 % 12, // assume AM
433        (None, None, _) => 0,
434    };
435
436    match target {
437        ParseTarget::DateOnly => {
438            // Year is required; month and day default to 1 (start of
439            // year / start of month).
440            let y = year?;
441            let m = month.unwrap_or(1);
442            let d = day.unwrap_or(1);
443            Date::new(y, m, d).ok().map(ParsedValue::Date)
444        }
445        ParseTarget::TimeOnly => {
446            // Hour is required (either 24h or 12h); m/s default to 0.
447            if hour24.is_none() && hour12.is_none() {
448                return None;
449            }
450            Time::new(hour, minute.unwrap_or(0), second.unwrap_or(0), 0)
451                .ok()
452                .map(ParsedValue::Time)
453        }
454        ParseTarget::DateTime => {
455            let y = year?;
456            let m = month.unwrap_or(1);
457            let d = day.unwrap_or(1);
458            let date = Date::new(y, m, d).ok()?;
459            let time = Time::new(hour, minute.unwrap_or(0), second.unwrap_or(0), 0).ok()?;
460            Some(ParsedValue::DateTime(DateTime::from_parts(date, time)))
461        }
462    }
463}
464
465/// Build an InputMask grammar string from a parsed pattern. Each
466/// digit segment becomes the corresponding number of `9` chars; the
467/// AM/PM period segment becomes `>AA` (two uppercase-locked letters);
468/// literals stay as fixed separators.
469///
470/// Pattern → mask:
471/// - `%Y-%m-%d` → `9999-99-99`
472/// - `%m/%d/%Y` → `99/99/9999`
473/// - `%H:%M:%S` → `99:99:99`
474/// - `%I:%M %p` → `99:99 >AA`
475///
476/// Backslashes in the pattern's literal positions are escaped via
477/// `\\` so the InputMask parser treats them as literals (otherwise
478/// `\X` in a literal would consume the following char).
479pub fn mask_for_pattern(pattern: &ParsedPattern) -> String {
480    let mut out = String::new();
481    for token in &pattern.tokens {
482        match token {
483            PatternToken::Literal(s) => {
484                for c in s.chars() {
485                    // Mask grammar metacharacters need escaping in
486                    // literal positions; everything else passes
487                    // through unchanged.
488                    if matches!(
489                        c,
490                        '9' | '0'
491                            | 'A'
492                            | 'a'
493                            | 'N'
494                            | 'n'
495                            | 'X'
496                            | 'x'
497                            | 'H'
498                            | 'h'
499                            | '>'
500                            | '<'
501                            | '!'
502                            | '\\'
503                    ) {
504                        out.push('\\');
505                    }
506                    out.push(c);
507                }
508            }
509            PatternToken::Segment(kind) => {
510                let digits = match kind {
511                    SegmentKind::Year => 4,
512                    SegmentKind::Month
513                    | SegmentKind::MonthShort
514                    | SegmentKind::Day
515                    | SegmentKind::DayShort
516                    | SegmentKind::Hour24
517                    | SegmentKind::Hour24Short
518                    | SegmentKind::Hour12
519                    | SegmentKind::Hour12Short
520                    | SegmentKind::Minute
521                    | SegmentKind::MinuteShort
522                    | SegmentKind::Second
523                    | SegmentKind::SecondShort => 2,
524                    SegmentKind::Period => {
525                        out.push_str(">AA");
526                        continue;
527                    }
528                };
529                for _ in 0..digits {
530                    out.push('9');
531                }
532            }
533        }
534    }
535    out
536}
537
538/// One editable segment's span in formatted-text display coordinates.
539/// `(start_char_offset, end_char_offset_exclusive, kind)`. Computed by
540/// walking [`ParsedPattern::tokens`] and accumulating the rendered
541/// width of each segment (4 for `Year`, 2 for `Period` (`AM`/`PM`),
542/// 2 for every digit segment) and each literal. Drives caret-in-
543/// segment lookups for segment-stepping and per-segment selection.
544pub fn segments_layout(pattern: &ParsedPattern) -> Vec<(usize, usize, SegmentKind)> {
545    let mut out = Vec::new();
546    let mut pos = 0usize;
547    for token in &pattern.tokens {
548        match token {
549            PatternToken::Literal(s) => pos += s.chars().count(),
550            PatternToken::Segment(kind) => {
551                let width = match kind {
552                    SegmentKind::Year => 4,
553                    // "AM" / "PM" — see `render_segment` for the
554                    // canonical width of every segment kind.
555                    SegmentKind::Period => 2,
556                    _ => 2,
557                };
558                out.push((pos, pos + width, *kind));
559                pos += width;
560            }
561        }
562    }
563    out
564}
565
566/// Find the segment under `caret_pos` (in formatted-text display
567/// coordinates). A caret resting on a segment boundary belongs to the
568/// segment to its right (typing-into semantics). A caret on a separator
569/// snaps to the *preceding* segment so Up/Down keep working when the
570/// caret is between two segments. Returns `None` only when the pattern
571/// has no editable segments at all.
572pub fn segment_at_position(
573    pattern: &ParsedPattern,
574    caret_pos: usize,
575) -> Option<(usize, usize, SegmentKind)> {
576    let layout = segments_layout(pattern);
577    // First pass: caret strictly inside a segment.
578    for &(start, end, kind) in &layout {
579        if caret_pos >= start && caret_pos < end {
580            return Some((start, end, kind));
581        }
582    }
583    // Caret on a separator or past the end: snap to the nearest
584    // segment that ENDS at-or-before the caret (preceding segment).
585    layout
586        .iter()
587        .rev()
588        .find(|(_, end, _)| *end <= caret_pos)
589        .copied()
590        .or_else(|| layout.first().copied())
591}
592
593/// Step a single field of a `Date` by `delta`. Year saturates at the
594/// jiff range and clamps the day if the new year+month no longer holds
595/// the current day (e.g. Feb 29 → Feb 28 in non-leap years). Month
596/// wraps within `[1, 12]` and clamps the day to the new month's last
597/// day. Day wraps within the current month — does not advance to the
598/// next month, matching Qt `QDateEdit` and macOS Calendar behaviour.
599/// Returns the input `date` unchanged when `kind` is not a date field.
600pub fn step_date_field(date: Date, kind: SegmentKind, delta: i32) -> Date {
601    match kind {
602        SegmentKind::Year => {
603            // `saturating_add` so a huge `delta` from the public API can't
604            // overflow i32 before the clamp.
605            let new_year = (date.year() as i32)
606                .saturating_add(delta)
607                .clamp(-9999, 9999) as i16;
608            let last_day = Date::new(new_year, date.month(), 1)
609                .map(|d| d.last_of_month().day())
610                .unwrap_or(date.day());
611            let day = date.day().min(last_day);
612            Date::new(new_year, date.month(), day).unwrap_or(date)
613        }
614        SegmentKind::Month | SegmentKind::MonthShort => {
615            // Reduce `delta` modulo 12 before adding so a large public-API
616            // `delta` can't overflow i32 (the result is identical modulo 12).
617            let new_month = ((date.month() as i32 - 1) + delta.rem_euclid(12)).rem_euclid(12) + 1;
618            let new_month = new_month as i8;
619            let last_day = Date::new(date.year(), new_month, 1)
620                .map(|d| d.last_of_month().day())
621                .unwrap_or(date.day());
622            let day = date.day().min(last_day);
623            Date::new(date.year(), new_month, day).unwrap_or(date)
624        }
625        SegmentKind::Day | SegmentKind::DayShort => {
626            let last_day = Date::new(date.year(), date.month(), 1)
627                .map(|d| d.last_of_month().day())
628                .unwrap_or(28) as i32;
629            // Reduce `delta` modulo the month length before adding (overflow-safe).
630            let new_day =
631                ((date.day() as i32 - 1) + delta.rem_euclid(last_day)).rem_euclid(last_day) + 1;
632            Date::new(date.year(), date.month(), new_day as i8).unwrap_or(date)
633        }
634        _ => date,
635    }
636}
637
638/// Step a single field of a `Time` by `delta`. Hour wraps in `[0, 24)`
639/// (whether 12h or 24h segment kind — internal storage is 24h).
640/// Minute and second wrap in `[0, 60)`. AM/PM toggles on any non-zero
641/// `delta` (sign-agnostic). Returns the input `time` unchanged when
642/// `kind` is not a time field.
643pub fn step_time_field(time: Time, kind: SegmentKind, delta: i32) -> Time {
644    match kind {
645        SegmentKind::Hour24
646        | SegmentKind::Hour24Short
647        | SegmentKind::Hour12
648        | SegmentKind::Hour12Short => {
649            // Reduce `delta` to the field period before adding, so a large
650            // public-API `delta` can't overflow i32 (identical result mod 24).
651            let h = (time.hour() as i32 + delta.rem_euclid(24)).rem_euclid(24) as i8;
652            Time::new(h, time.minute(), time.second(), 0).unwrap_or(time)
653        }
654        SegmentKind::Minute | SegmentKind::MinuteShort => {
655            let m = (time.minute() as i32 + delta.rem_euclid(60)).rem_euclid(60) as i8;
656            Time::new(time.hour(), m, time.second(), 0).unwrap_or(time)
657        }
658        SegmentKind::Second | SegmentKind::SecondShort => {
659            let s = (time.second() as i32 + delta.rem_euclid(60)).rem_euclid(60) as i8;
660            Time::new(time.hour(), time.minute(), s, 0).unwrap_or(time)
661        }
662        SegmentKind::Period => {
663            if delta == 0 {
664                return time;
665            }
666            let h = (time.hour() as i32 + 12).rem_euclid(24) as i8;
667            Time::new(h, time.minute(), time.second(), 0).unwrap_or(time)
668        }
669        _ => time,
670    }
671}
672
673/// Consume up to 2 ASCII letters at the front of `cursor` (so "AM",
674/// "Am", "PM", or a bare "A"/"P" mid-typing all advance the cursor
675/// correctly). Returns the trimmed slice.
676fn consume_period_letters(cursor: &str) -> &str {
677    let mut end = 0;
678    for (i, ch) in cursor.char_indices() {
679        if ch.is_ascii_alphabetic() && end < 2 {
680            end = i + ch.len_utf8();
681        } else {
682            break;
683        }
684    }
685    &cursor[end..]
686}
687
688/// Take up to `max` ASCII-digit characters from the front of the
689/// string. Returns `None` if there are zero digits at the front.
690fn take_digits(s: &str, max: usize) -> Option<(&str, &str)> {
691    let mut end = 0;
692    for (i, ch) in s.char_indices() {
693        if ch.is_ascii_digit() && end < max {
694            end = i + ch.len_utf8();
695        } else {
696            break;
697        }
698    }
699    if end == 0 {
700        None
701    } else {
702        Some((&s[..end], &s[end..]))
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709
710    #[test]
711    fn parses_iso_pattern() {
712        let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
713        assert_eq!(
714            pat.tokens,
715            vec![
716                PatternToken::Segment(SegmentKind::Year),
717                PatternToken::Literal("-".to_string()),
718                PatternToken::Segment(SegmentKind::Month),
719                PatternToken::Literal("-".to_string()),
720                PatternToken::Segment(SegmentKind::Day),
721            ]
722        );
723    }
724
725    #[test]
726    fn parses_us_pattern() {
727        let pat = ParsedPattern::parse("%m/%d/%Y").unwrap();
728        let segs: Vec<_> = pat.segments().collect();
729        assert_eq!(
730            segs,
731            vec![SegmentKind::Month, SegmentKind::Day, SegmentKind::Year]
732        );
733    }
734
735    #[test]
736    fn parses_dotted_european_pattern() {
737        let pat = ParsedPattern::parse("%d.%m.%Y").unwrap();
738        assert_eq!(pat.segments().count(), 3);
739    }
740
741    #[test]
742    fn rejects_unsupported_directive() {
743        assert!(matches!(
744            ParsedPattern::parse("%Y-%B-%d"),
745            Err(PatternError::UnsupportedDirective { directive: 'B', .. })
746        ));
747    }
748
749    #[test]
750    fn rejects_trailing_percent() {
751        assert_eq!(
752            ParsedPattern::parse("%Y-%"),
753            Err(PatternError::TrailingPercent)
754        );
755    }
756
757    #[test]
758    fn round_trip_iso() {
759        let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
760        let date = Date::constant(2026, 5, 2);
761        let s = format_value(&pat, Some(date), None);
762        assert_eq!(s, "2026-05-02");
763        match parse_value(&pat, &s, ParseTarget::DateOnly) {
764            Some(ParsedValue::Date(d)) => assert_eq!(d, date),
765            other => panic!("expected Date, got {other:?}"),
766        }
767    }
768
769    #[test]
770    fn round_trip_us() {
771        let pat = ParsedPattern::parse("%m/%d/%Y").unwrap();
772        let date = Date::constant(2026, 5, 2);
773        let s = format_value(&pat, Some(date), None);
774        assert_eq!(s, "05/02/2026");
775        match parse_value(&pat, &s, ParseTarget::DateOnly) {
776            Some(ParsedValue::Date(d)) => assert_eq!(d, date),
777            other => panic!("expected Date, got {other:?}"),
778        }
779    }
780
781    #[test]
782    fn parse_rejects_out_of_range_month() {
783        let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
784        assert!(parse_value(&pat, "2026-13-02", ParseTarget::DateOnly).is_none());
785    }
786
787    #[test]
788    fn parse_rejects_invalid_separator() {
789        let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
790        assert!(parse_value(&pat, "2026/05/02", ParseTarget::DateOnly).is_none());
791    }
792
793    #[test]
794    fn time_round_trip_24h() {
795        let pat = ParsedPattern::parse("%H:%M:%S").unwrap();
796        let time = Time::new(14, 35, 7, 0).unwrap();
797        let s = format_value(&pat, None, Some(time));
798        assert_eq!(s, "14:35:07");
799        match parse_value(&pat, &s, ParseTarget::TimeOnly) {
800            Some(ParsedValue::Time(t)) => assert_eq!(t, time),
801            other => panic!("expected Time, got {other:?}"),
802        }
803    }
804
805    #[test]
806    fn time_round_trip_12h_with_period() {
807        let pat = ParsedPattern::parse("%I:%M %p").unwrap();
808        let time = Time::new(14, 35, 0, 0).unwrap();
809        let s = format_value(&pat, None, Some(time));
810        assert_eq!(s, "02:35 PM");
811        match parse_value(&pat, &s, ParseTarget::TimeOnly) {
812            Some(ParsedValue::Time(t)) => assert_eq!(t.hour(), 14),
813            other => panic!("expected Time, got {other:?}"),
814        }
815    }
816
817    #[test]
818    fn lenient_year_only_defaults_month_and_day() {
819        let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
820        match parse_value(&pat, "2026", ParseTarget::DateOnly) {
821            Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 1, 1)),
822            other => panic!("expected Date(2026,1,1), got {other:?}"),
823        }
824    }
825
826    #[test]
827    fn lenient_year_month_only_defaults_day() {
828        let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
829        match parse_value(&pat, "2026-5", ParseTarget::DateOnly) {
830            Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 5, 1)),
831            other => panic!("expected Date(2026,5,1), got {other:?}"),
832        }
833    }
834
835    #[test]
836    fn lenient_trailing_separator_accepted() {
837        let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
838        match parse_value(&pat, "2026-", ParseTarget::DateOnly) {
839            Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 1, 1)),
840            other => panic!("expected Date(2026,1,1), got {other:?}"),
841        }
842        match parse_value(&pat, "2026-5-", ParseTarget::DateOnly) {
843            Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 5, 1)),
844            other => panic!("expected Date(2026,5,1), got {other:?}"),
845        }
846    }
847
848    #[test]
849    fn lenient_two_digit_month_one_digit_day() {
850        // Tests that `take_digits(2)` accepts a 1-digit run when the
851        // pattern asked for 2-digit month/day. Already worked before
852        // the lenient change but worth a regression guard.
853        let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
854        match parse_value(&pat, "2026-5-2", ParseTarget::DateOnly) {
855            Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 5, 2)),
856            other => panic!("expected Date(2026,5,2), got {other:?}"),
857        }
858    }
859
860    #[test]
861    fn lenient_empty_input_rejects() {
862        let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
863        assert!(parse_value(&pat, "", ParseTarget::DateOnly).is_none());
864        assert!(parse_value(&pat, "   ", ParseTarget::DateOnly).is_none());
865    }
866
867    #[test]
868    fn lenient_us_pattern_partial_input() {
869        // `%m/%d/%Y` — month required first; trailing year/day fill
870        // with defaults.
871        let pat = ParsedPattern::parse("%m/%d/%Y").unwrap();
872        match parse_value(&pat, "5", ParseTarget::DateOnly) {
873            // No year → reject. Year is the *third* segment in MDY
874            // pattern, so a valid lenient parse needs it present.
875            None => {}
876            other => panic!("expected None (no year), got {other:?}"),
877        }
878        match parse_value(&pat, "5/2/2026", ParseTarget::DateOnly) {
879            Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 5, 2)),
880            other => panic!("expected Date, got {other:?}"),
881        }
882    }
883
884    #[test]
885    fn lenient_time_partial() {
886        let pat = ParsedPattern::parse("%H:%M:%S").unwrap();
887        // Hour-only → minute and second default to 0.
888        match parse_value(&pat, "14", ParseTarget::TimeOnly) {
889            Some(ParsedValue::Time(t)) => assert_eq!(t, Time::new(14, 0, 0, 0).unwrap()),
890            other => panic!("expected Time(14:00:00), got {other:?}"),
891        }
892        // Hour:minute → second defaults to 0.
893        match parse_value(&pat, "14:35", ParseTarget::TimeOnly) {
894            Some(ParsedValue::Time(t)) => assert_eq!(t, Time::new(14, 35, 0, 0).unwrap()),
895            other => panic!("expected Time(14:35:00), got {other:?}"),
896        }
897    }
898
899    #[test]
900    fn mask_for_iso_date() {
901        let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
902        assert_eq!(mask_for_pattern(&pat), "9999-99-99");
903    }
904
905    #[test]
906    fn mask_for_us_date() {
907        let pat = ParsedPattern::parse("%m/%d/%Y").unwrap();
908        assert_eq!(mask_for_pattern(&pat), "99/99/9999");
909    }
910
911    #[test]
912    fn mask_for_european_date() {
913        let pat = ParsedPattern::parse("%d.%m.%Y").unwrap();
914        assert_eq!(mask_for_pattern(&pat), "99.99.9999");
915    }
916
917    #[test]
918    fn mask_for_24h_time() {
919        let pat = ParsedPattern::parse("%H:%M").unwrap();
920        assert_eq!(mask_for_pattern(&pat), "99:99");
921        let pat = ParsedPattern::parse("%H:%M:%S").unwrap();
922        assert_eq!(mask_for_pattern(&pat), "99:99:99");
923    }
924
925    #[test]
926    fn mask_for_12h_time() {
927        let pat = ParsedPattern::parse("%I:%M %p").unwrap();
928        assert_eq!(mask_for_pattern(&pat), "99:99 >AA");
929    }
930
931    #[test]
932    fn segment_kind_max_digits() {
933        assert_eq!(SegmentKind::Year.max_digits(), 4);
934        assert_eq!(SegmentKind::Month.max_digits(), 2);
935        assert_eq!(SegmentKind::Day.max_digits(), 2);
936        assert_eq!(SegmentKind::Period.max_digits(), 0);
937    }
938
939    // ── Segment layout / position lookup ────────────────────────────
940
941    #[test]
942    fn segments_layout_iso_pattern() {
943        let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
944        assert_eq!(
945            segments_layout(&pat),
946            vec![
947                (0, 4, SegmentKind::Year),
948                (5, 7, SegmentKind::Month),
949                (8, 10, SegmentKind::Day),
950            ]
951        );
952    }
953
954    #[test]
955    fn segments_layout_with_period() {
956        let pat = ParsedPattern::parse("%I:%M %p").unwrap();
957        assert_eq!(
958            segments_layout(&pat),
959            vec![
960                (0, 2, SegmentKind::Hour12),
961                (3, 5, SegmentKind::Minute),
962                (6, 8, SegmentKind::Period),
963            ]
964        );
965    }
966
967    #[test]
968    fn segment_at_position_inside_segments() {
969        let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
970        // caret 0..4 → Year
971        assert_eq!(
972            segment_at_position(&pat, 0).map(|s| s.2),
973            Some(SegmentKind::Year)
974        );
975        assert_eq!(
976            segment_at_position(&pat, 3).map(|s| s.2),
977            Some(SegmentKind::Year)
978        );
979        // caret 5..7 → Month
980        assert_eq!(
981            segment_at_position(&pat, 5).map(|s| s.2),
982            Some(SegmentKind::Month)
983        );
984        assert_eq!(
985            segment_at_position(&pat, 6).map(|s| s.2),
986            Some(SegmentKind::Month)
987        );
988        // caret 8..10 → Day
989        assert_eq!(
990            segment_at_position(&pat, 9).map(|s| s.2),
991            Some(SegmentKind::Day)
992        );
993    }
994
995    #[test]
996    fn segment_at_position_on_separator_snaps_left() {
997        let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
998        // caret 4 sits on the boundary at end of Year / start of `-`.
999        // Snaps to Year (preceding segment).
1000        assert_eq!(
1001            segment_at_position(&pat, 4).map(|s| s.2),
1002            Some(SegmentKind::Year)
1003        );
1004        // caret 7 = end of Month, sits on boundary with `-`.
1005        assert_eq!(
1006            segment_at_position(&pat, 7).map(|s| s.2),
1007            Some(SegmentKind::Month)
1008        );
1009        // caret 10 = end of Day (text end). Snaps to Day.
1010        assert_eq!(
1011            segment_at_position(&pat, 10).map(|s| s.2),
1012            Some(SegmentKind::Day)
1013        );
1014    }
1015
1016    // ── step_date_field ─────────────────────────────────────────────
1017
1018    #[test]
1019    fn step_year_basic_increment() {
1020        let d = Date::new(2026, 5, 15).unwrap();
1021        assert_eq!(
1022            step_date_field(d, SegmentKind::Year, 1),
1023            Date::new(2027, 5, 15).unwrap()
1024        );
1025        assert_eq!(
1026            step_date_field(d, SegmentKind::Year, -10),
1027            Date::new(2016, 5, 15).unwrap()
1028        );
1029    }
1030
1031    #[test]
1032    fn step_year_clamps_feb_29() {
1033        let leap = Date::new(2024, 2, 29).unwrap();
1034        // Stepping to 2025 (non-leap) clamps day to 28.
1035        assert_eq!(
1036            step_date_field(leap, SegmentKind::Year, 1),
1037            Date::new(2025, 2, 28).unwrap()
1038        );
1039    }
1040
1041    #[test]
1042    fn step_fields_dont_overflow_on_extreme_delta() {
1043        // The public API accepts any i32 delta. Extreme values must not
1044        // overflow the intermediate arithmetic (panic in debug).
1045        let d = Date::new(2026, 5, 15).unwrap();
1046        // Year saturates into the valid range.
1047        let _ = step_date_field(d, SegmentKind::Year, i32::MAX);
1048        let _ = step_date_field(d, SegmentKind::Year, i32::MIN);
1049        // Month / day wrap with the delta reduced modulo the period.
1050        assert_eq!(
1051            step_date_field(d, SegmentKind::Month, i32::MAX),
1052            step_date_field(d, SegmentKind::Month, i32::MAX.rem_euclid(12)),
1053        );
1054        let _ = step_date_field(d, SegmentKind::Day, i32::MIN);
1055        let t = Time::new(10, 30, 0, 0).unwrap();
1056        let _ = step_time_field(t, SegmentKind::Hour24, i32::MAX);
1057        let _ = step_time_field(t, SegmentKind::Minute, i32::MIN);
1058        let _ = step_time_field(t, SegmentKind::Second, i32::MAX);
1059    }
1060
1061    #[test]
1062    fn step_month_wraps_within_year() {
1063        // Dec → Jan stays in same year (display-segment wrap).
1064        let d = Date::new(2026, 12, 5).unwrap();
1065        assert_eq!(
1066            step_date_field(d, SegmentKind::Month, 1),
1067            Date::new(2026, 1, 5).unwrap()
1068        );
1069        // Jan → Dec
1070        let d = Date::new(2026, 1, 5).unwrap();
1071        assert_eq!(
1072            step_date_field(d, SegmentKind::Month, -1),
1073            Date::new(2026, 12, 5).unwrap()
1074        );
1075    }
1076
1077    #[test]
1078    fn step_month_clamps_day() {
1079        // Mar 31 → Feb (28 in 2026)
1080        let d = Date::new(2026, 3, 31).unwrap();
1081        assert_eq!(
1082            step_date_field(d, SegmentKind::Month, -1),
1083            Date::new(2026, 2, 28).unwrap()
1084        );
1085    }
1086
1087    #[test]
1088    fn step_day_wraps_within_month() {
1089        // 31 + 1 in March → 1 (same month, not April).
1090        let d = Date::new(2026, 3, 31).unwrap();
1091        assert_eq!(
1092            step_date_field(d, SegmentKind::Day, 1),
1093            Date::new(2026, 3, 1).unwrap()
1094        );
1095        // 1 - 1 in March → 31
1096        let d = Date::new(2026, 3, 1).unwrap();
1097        assert_eq!(
1098            step_date_field(d, SegmentKind::Day, -1),
1099            Date::new(2026, 3, 31).unwrap()
1100        );
1101    }
1102
1103    // ── step_time_field ─────────────────────────────────────────────
1104
1105    #[test]
1106    fn step_hour_wraps_24h() {
1107        let t = Time::new(23, 30, 0, 0).unwrap();
1108        assert_eq!(
1109            step_time_field(t, SegmentKind::Hour24, 1),
1110            Time::new(0, 30, 0, 0).unwrap()
1111        );
1112        let t = Time::new(0, 30, 0, 0).unwrap();
1113        assert_eq!(
1114            step_time_field(t, SegmentKind::Hour24, -1),
1115            Time::new(23, 30, 0, 0).unwrap()
1116        );
1117    }
1118
1119    #[test]
1120    fn step_minute_wraps_60() {
1121        let t = Time::new(10, 59, 0, 0).unwrap();
1122        assert_eq!(
1123            step_time_field(t, SegmentKind::Minute, 1),
1124            Time::new(10, 0, 0, 0).unwrap()
1125        );
1126    }
1127
1128    #[test]
1129    fn step_period_toggles_am_pm() {
1130        let am = Time::new(9, 0, 0, 0).unwrap();
1131        assert_eq!(
1132            step_time_field(am, SegmentKind::Period, 1),
1133            Time::new(21, 0, 0, 0).unwrap()
1134        );
1135        let pm = Time::new(15, 30, 0, 0).unwrap();
1136        assert_eq!(
1137            step_time_field(pm, SegmentKind::Period, -1),
1138            Time::new(3, 30, 0, 0).unwrap()
1139        );
1140        // Zero delta is a no-op
1141        assert_eq!(step_time_field(am, SegmentKind::Period, 0), am);
1142    }
1143}