use jiff::civil::{Date, DateTime, Time};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PatternToken {
Segment(SegmentKind),
Literal(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SegmentKind {
Year,
Month,
MonthShort,
Day,
DayShort,
Hour24,
Hour24Short,
Hour12,
Hour12Short,
Minute,
MinuteShort,
Second,
SecondShort,
Period,
}
impl SegmentKind {
pub fn max_digits(self) -> usize {
match self {
Self::Year => 4,
Self::Month | Self::Day | Self::Hour24 | Self::Hour12 | Self::Minute | Self::Second => {
2
}
Self::MonthShort
| Self::DayShort
| Self::Hour24Short
| Self::Hour12Short
| Self::MinuteShort
| Self::SecondShort => 2,
Self::Period => 0,
}
}
pub fn value_range(self) -> Option<(i32, i32)> {
match self {
Self::Year => Some((-9999, 9999)),
Self::Month | Self::MonthShort => Some((1, 12)),
Self::Day | Self::DayShort => Some((1, 31)),
Self::Hour24 | Self::Hour24Short => Some((0, 23)),
Self::Hour12 | Self::Hour12Short => Some((1, 12)),
Self::Minute | Self::MinuteShort | Self::Second | Self::SecondShort => Some((0, 59)),
Self::Period => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedPattern {
pub tokens: Vec<PatternToken>,
}
impl ParsedPattern {
pub fn parse(pattern: &str) -> Result<Self, PatternError> {
let mut tokens = Vec::new();
let mut literal = String::new();
let mut chars = pattern.chars().peekable();
let flush_literal = |lit: &mut String, tokens: &mut Vec<PatternToken>| {
if !lit.is_empty() {
tokens.push(PatternToken::Literal(std::mem::take(lit)));
}
};
while let Some(c) = chars.next() {
if c != '%' {
literal.push(c);
continue;
}
let no_pad = matches!(chars.peek(), Some('-'));
if no_pad {
chars.next();
}
let Some(d) = chars.next() else {
return Err(PatternError::TrailingPercent);
};
let segment = match (no_pad, d) {
(false, 'Y') => SegmentKind::Year,
(true, 'Y') => SegmentKind::Year,
(false, 'm') => SegmentKind::Month,
(true, 'm') => SegmentKind::MonthShort,
(false, 'd') => SegmentKind::Day,
(true, 'd') => SegmentKind::DayShort,
(false, 'H') => SegmentKind::Hour24,
(true, 'H') => SegmentKind::Hour24Short,
(false, 'I') => SegmentKind::Hour12,
(true, 'I') => SegmentKind::Hour12Short,
(false, 'M') => SegmentKind::Minute,
(true, 'M') => SegmentKind::MinuteShort,
(false, 'S') => SegmentKind::Second,
(true, 'S') => SegmentKind::SecondShort,
(false, 'p') => SegmentKind::Period,
(false, '%') => {
literal.push('%');
continue;
}
(no_pad, ch) => {
return Err(PatternError::UnsupportedDirective {
directive: ch,
with_dash_modifier: no_pad,
});
}
};
flush_literal(&mut literal, &mut tokens);
tokens.push(PatternToken::Segment(segment));
}
flush_literal(&mut literal, &mut tokens);
Ok(Self { tokens })
}
pub fn segments(&self) -> impl Iterator<Item = SegmentKind> + '_ {
self.tokens.iter().filter_map(|t| match t {
PatternToken::Segment(k) => Some(*k),
_ => None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PatternError {
#[error("trailing `%` at end of pattern")]
TrailingPercent,
#[error(
"{}",
if *with_dash_modifier {
format!("unsupported directive `%-{directive}` in pattern")
} else {
format!("unsupported directive `%{directive}` in pattern")
}
)]
UnsupportedDirective {
directive: char,
with_dash_modifier: bool,
},
}
pub fn segment_value_for_date(d: Date, kind: SegmentKind) -> Option<i32> {
Some(match kind {
SegmentKind::Year => d.year() as i32,
SegmentKind::Month | SegmentKind::MonthShort => d.month() as i32,
SegmentKind::Day | SegmentKind::DayShort => d.day() as i32,
_ => return None,
})
}
pub fn segment_value_for_time(t: Time, kind: SegmentKind) -> Option<i32> {
Some(match kind {
SegmentKind::Hour24 | SegmentKind::Hour24Short => t.hour() as i32,
SegmentKind::Hour12 | SegmentKind::Hour12Short => {
let h = t.hour() as i32;
let h12 = h % 12;
if h12 == 0 { 12 } else { h12 }
}
SegmentKind::Minute | SegmentKind::MinuteShort => t.minute() as i32,
SegmentKind::Second | SegmentKind::SecondShort => t.second() as i32,
SegmentKind::Period => {
if t.hour() < 12 {
0
} else {
1
}
}
_ => return None,
})
}
pub fn render_segment(kind: SegmentKind, value: i32) -> String {
match kind {
SegmentKind::Year => {
if value < 0 {
format!("-{:04}", value.unsigned_abs())
} else {
format!("{:04}", value)
}
}
SegmentKind::Month
| SegmentKind::Day
| SegmentKind::Hour24
| SegmentKind::Hour12
| SegmentKind::Minute
| SegmentKind::Second => format!("{:02}", value),
SegmentKind::MonthShort
| SegmentKind::DayShort
| SegmentKind::Hour24Short
| SegmentKind::Hour12Short
| SegmentKind::MinuteShort
| SegmentKind::SecondShort => format!("{}", value),
SegmentKind::Period => {
if value == 0 {
"AM".to_string()
} else {
"PM".to_string()
}
}
}
}
pub fn format_value(pattern: &ParsedPattern, date: Option<Date>, time: Option<Time>) -> String {
let mut out = String::new();
for token in &pattern.tokens {
match token {
PatternToken::Literal(s) => out.push_str(s),
PatternToken::Segment(kind) => {
let value = match (date, time) {
(Some(d), _) if segment_value_for_date(d, *kind).is_some() => {
segment_value_for_date(d, *kind).expect("guarded by is_some() above")
}
(_, Some(t)) => segment_value_for_time(t, *kind).unwrap_or(0),
(Some(d), None) => segment_value_for_date(d, *kind).unwrap_or(0),
(None, None) => 0,
};
out.push_str(&render_segment(*kind, value));
}
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseTarget {
DateOnly,
TimeOnly,
DateTime,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParsedValue {
Date(Date),
Time(Time),
DateTime(DateTime),
}
pub fn parse_value(
pattern: &ParsedPattern,
input: &str,
target: ParseTarget,
) -> Option<ParsedValue> {
let mut cursor = input;
let mut year: Option<i16> = None;
let mut month: Option<i8> = None;
let mut day: Option<i8> = None;
let mut hour24: Option<i8> = None;
let mut hour12: Option<i8> = None;
let mut minute: Option<i8> = None;
let mut second: Option<i8> = None;
let mut period: Option<i8> = None;
for token in &pattern.tokens {
if cursor.is_empty() {
break;
}
match token {
PatternToken::Literal(lit) => {
if let Some(trimmed) = cursor.strip_prefix(lit.as_str()) {
cursor = trimmed;
} else if lit.starts_with(cursor) {
cursor = "";
} else {
return None;
}
}
PatternToken::Segment(kind) => {
if matches!(kind, SegmentKind::Period) {
let first_char = cursor.chars().next()?;
let upper = first_char.to_ascii_uppercase();
match upper {
'A' => {
period = Some(0);
cursor = consume_period_letters(cursor);
}
'P' => {
period = Some(1);
cursor = consume_period_letters(cursor);
}
_ => return None,
}
continue;
}
let max = kind.max_digits();
let Some((digits, rest)) = take_digits(cursor, max) else {
break;
};
cursor = rest;
let v: i32 = digits.parse().ok()?;
let (lo, hi) = kind.value_range().unwrap_or((i32::MIN, i32::MAX));
if v < lo || v > hi {
return None;
}
match kind {
SegmentKind::Year => year = Some(v as i16),
SegmentKind::Month | SegmentKind::MonthShort => month = Some(v as i8),
SegmentKind::Day | SegmentKind::DayShort => day = Some(v as i8),
SegmentKind::Hour24 | SegmentKind::Hour24Short => hour24 = Some(v as i8),
SegmentKind::Hour12 | SegmentKind::Hour12Short => hour12 = Some(v as i8),
SegmentKind::Minute | SegmentKind::MinuteShort => minute = Some(v as i8),
SegmentKind::Second | SegmentKind::SecondShort => second = Some(v as i8),
SegmentKind::Period => unreachable!(),
}
}
}
}
if !cursor.trim().is_empty() {
return None;
}
let hour = match (hour24, hour12, period) {
(Some(h), _, _) => h,
(None, Some(h12), Some(p)) => {
let base = h12 % 12;
base + if p == 1 { 12 } else { 0 }
}
(None, Some(h12), None) => h12 % 12, (None, None, _) => 0,
};
match target {
ParseTarget::DateOnly => {
let y = year?;
let m = month.unwrap_or(1);
let d = day.unwrap_or(1);
Date::new(y, m, d).ok().map(ParsedValue::Date)
}
ParseTarget::TimeOnly => {
if hour24.is_none() && hour12.is_none() {
return None;
}
Time::new(hour, minute.unwrap_or(0), second.unwrap_or(0), 0)
.ok()
.map(ParsedValue::Time)
}
ParseTarget::DateTime => {
let y = year?;
let m = month.unwrap_or(1);
let d = day.unwrap_or(1);
let date = Date::new(y, m, d).ok()?;
let time = Time::new(hour, minute.unwrap_or(0), second.unwrap_or(0), 0).ok()?;
Some(ParsedValue::DateTime(DateTime::from_parts(date, time)))
}
}
}
pub fn mask_for_pattern(pattern: &ParsedPattern) -> String {
let mut out = String::new();
for token in &pattern.tokens {
match token {
PatternToken::Literal(s) => {
for c in s.chars() {
if matches!(
c,
'9' | '0'
| 'A'
| 'a'
| 'N'
| 'n'
| 'X'
| 'x'
| 'H'
| 'h'
| '>'
| '<'
| '!'
| '\\'
) {
out.push('\\');
}
out.push(c);
}
}
PatternToken::Segment(kind) => {
let digits = match kind {
SegmentKind::Year => 4,
SegmentKind::Month
| SegmentKind::MonthShort
| SegmentKind::Day
| SegmentKind::DayShort
| SegmentKind::Hour24
| SegmentKind::Hour24Short
| SegmentKind::Hour12
| SegmentKind::Hour12Short
| SegmentKind::Minute
| SegmentKind::MinuteShort
| SegmentKind::Second
| SegmentKind::SecondShort => 2,
SegmentKind::Period => {
out.push_str(">AA");
continue;
}
};
for _ in 0..digits {
out.push('9');
}
}
}
}
out
}
pub fn segments_layout(pattern: &ParsedPattern) -> Vec<(usize, usize, SegmentKind)> {
let mut out = Vec::new();
let mut pos = 0usize;
for token in &pattern.tokens {
match token {
PatternToken::Literal(s) => pos += s.chars().count(),
PatternToken::Segment(kind) => {
let width = match kind {
SegmentKind::Year => 4,
SegmentKind::Period => 2,
_ => 2,
};
out.push((pos, pos + width, *kind));
pos += width;
}
}
}
out
}
pub fn segment_at_position(
pattern: &ParsedPattern,
caret_pos: usize,
) -> Option<(usize, usize, SegmentKind)> {
let layout = segments_layout(pattern);
for &(start, end, kind) in &layout {
if caret_pos >= start && caret_pos < end {
return Some((start, end, kind));
}
}
layout
.iter()
.rev()
.find(|(_, end, _)| *end <= caret_pos)
.copied()
.or_else(|| layout.first().copied())
}
pub fn step_date_field(date: Date, kind: SegmentKind, delta: i32) -> Date {
match kind {
SegmentKind::Year => {
let new_year = (date.year() as i32)
.saturating_add(delta)
.clamp(-9999, 9999) as i16;
let last_day = Date::new(new_year, date.month(), 1)
.map(|d| d.last_of_month().day())
.unwrap_or(date.day());
let day = date.day().min(last_day);
Date::new(new_year, date.month(), day).unwrap_or(date)
}
SegmentKind::Month | SegmentKind::MonthShort => {
let new_month = ((date.month() as i32 - 1) + delta.rem_euclid(12)).rem_euclid(12) + 1;
let new_month = new_month as i8;
let last_day = Date::new(date.year(), new_month, 1)
.map(|d| d.last_of_month().day())
.unwrap_or(date.day());
let day = date.day().min(last_day);
Date::new(date.year(), new_month, day).unwrap_or(date)
}
SegmentKind::Day | SegmentKind::DayShort => {
let last_day = Date::new(date.year(), date.month(), 1)
.map(|d| d.last_of_month().day())
.unwrap_or(28) as i32;
let new_day =
((date.day() as i32 - 1) + delta.rem_euclid(last_day)).rem_euclid(last_day) + 1;
Date::new(date.year(), date.month(), new_day as i8).unwrap_or(date)
}
_ => date,
}
}
pub fn step_time_field(time: Time, kind: SegmentKind, delta: i32) -> Time {
match kind {
SegmentKind::Hour24
| SegmentKind::Hour24Short
| SegmentKind::Hour12
| SegmentKind::Hour12Short => {
let h = (time.hour() as i32 + delta.rem_euclid(24)).rem_euclid(24) as i8;
Time::new(h, time.minute(), time.second(), 0).unwrap_or(time)
}
SegmentKind::Minute | SegmentKind::MinuteShort => {
let m = (time.minute() as i32 + delta.rem_euclid(60)).rem_euclid(60) as i8;
Time::new(time.hour(), m, time.second(), 0).unwrap_or(time)
}
SegmentKind::Second | SegmentKind::SecondShort => {
let s = (time.second() as i32 + delta.rem_euclid(60)).rem_euclid(60) as i8;
Time::new(time.hour(), time.minute(), s, 0).unwrap_or(time)
}
SegmentKind::Period => {
if delta == 0 {
return time;
}
let h = (time.hour() as i32 + 12).rem_euclid(24) as i8;
Time::new(h, time.minute(), time.second(), 0).unwrap_or(time)
}
_ => time,
}
}
fn consume_period_letters(cursor: &str) -> &str {
let mut end = 0;
for (i, ch) in cursor.char_indices() {
if ch.is_ascii_alphabetic() && end < 2 {
end = i + ch.len_utf8();
} else {
break;
}
}
&cursor[end..]
}
fn take_digits(s: &str, max: usize) -> Option<(&str, &str)> {
let mut end = 0;
for (i, ch) in s.char_indices() {
if ch.is_ascii_digit() && end < max {
end = i + ch.len_utf8();
} else {
break;
}
}
if end == 0 {
None
} else {
Some((&s[..end], &s[end..]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_iso_pattern() {
let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
assert_eq!(
pat.tokens,
vec![
PatternToken::Segment(SegmentKind::Year),
PatternToken::Literal("-".to_string()),
PatternToken::Segment(SegmentKind::Month),
PatternToken::Literal("-".to_string()),
PatternToken::Segment(SegmentKind::Day),
]
);
}
#[test]
fn parses_us_pattern() {
let pat = ParsedPattern::parse("%m/%d/%Y").unwrap();
let segs: Vec<_> = pat.segments().collect();
assert_eq!(
segs,
vec![SegmentKind::Month, SegmentKind::Day, SegmentKind::Year]
);
}
#[test]
fn parses_dotted_european_pattern() {
let pat = ParsedPattern::parse("%d.%m.%Y").unwrap();
assert_eq!(pat.segments().count(), 3);
}
#[test]
fn rejects_unsupported_directive() {
assert!(matches!(
ParsedPattern::parse("%Y-%B-%d"),
Err(PatternError::UnsupportedDirective { directive: 'B', .. })
));
}
#[test]
fn rejects_trailing_percent() {
assert_eq!(
ParsedPattern::parse("%Y-%"),
Err(PatternError::TrailingPercent)
);
}
#[test]
fn round_trip_iso() {
let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
let date = Date::constant(2026, 5, 2);
let s = format_value(&pat, Some(date), None);
assert_eq!(s, "2026-05-02");
match parse_value(&pat, &s, ParseTarget::DateOnly) {
Some(ParsedValue::Date(d)) => assert_eq!(d, date),
other => panic!("expected Date, got {other:?}"),
}
}
#[test]
fn round_trip_us() {
let pat = ParsedPattern::parse("%m/%d/%Y").unwrap();
let date = Date::constant(2026, 5, 2);
let s = format_value(&pat, Some(date), None);
assert_eq!(s, "05/02/2026");
match parse_value(&pat, &s, ParseTarget::DateOnly) {
Some(ParsedValue::Date(d)) => assert_eq!(d, date),
other => panic!("expected Date, got {other:?}"),
}
}
#[test]
fn parse_rejects_out_of_range_month() {
let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
assert!(parse_value(&pat, "2026-13-02", ParseTarget::DateOnly).is_none());
}
#[test]
fn parse_rejects_invalid_separator() {
let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
assert!(parse_value(&pat, "2026/05/02", ParseTarget::DateOnly).is_none());
}
#[test]
fn time_round_trip_24h() {
let pat = ParsedPattern::parse("%H:%M:%S").unwrap();
let time = Time::new(14, 35, 7, 0).unwrap();
let s = format_value(&pat, None, Some(time));
assert_eq!(s, "14:35:07");
match parse_value(&pat, &s, ParseTarget::TimeOnly) {
Some(ParsedValue::Time(t)) => assert_eq!(t, time),
other => panic!("expected Time, got {other:?}"),
}
}
#[test]
fn time_round_trip_12h_with_period() {
let pat = ParsedPattern::parse("%I:%M %p").unwrap();
let time = Time::new(14, 35, 0, 0).unwrap();
let s = format_value(&pat, None, Some(time));
assert_eq!(s, "02:35 PM");
match parse_value(&pat, &s, ParseTarget::TimeOnly) {
Some(ParsedValue::Time(t)) => assert_eq!(t.hour(), 14),
other => panic!("expected Time, got {other:?}"),
}
}
#[test]
fn lenient_year_only_defaults_month_and_day() {
let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
match parse_value(&pat, "2026", ParseTarget::DateOnly) {
Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 1, 1)),
other => panic!("expected Date(2026,1,1), got {other:?}"),
}
}
#[test]
fn lenient_year_month_only_defaults_day() {
let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
match parse_value(&pat, "2026-5", ParseTarget::DateOnly) {
Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 5, 1)),
other => panic!("expected Date(2026,5,1), got {other:?}"),
}
}
#[test]
fn lenient_trailing_separator_accepted() {
let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
match parse_value(&pat, "2026-", ParseTarget::DateOnly) {
Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 1, 1)),
other => panic!("expected Date(2026,1,1), got {other:?}"),
}
match parse_value(&pat, "2026-5-", ParseTarget::DateOnly) {
Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 5, 1)),
other => panic!("expected Date(2026,5,1), got {other:?}"),
}
}
#[test]
fn lenient_two_digit_month_one_digit_day() {
let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
match parse_value(&pat, "2026-5-2", ParseTarget::DateOnly) {
Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 5, 2)),
other => panic!("expected Date(2026,5,2), got {other:?}"),
}
}
#[test]
fn lenient_empty_input_rejects() {
let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
assert!(parse_value(&pat, "", ParseTarget::DateOnly).is_none());
assert!(parse_value(&pat, " ", ParseTarget::DateOnly).is_none());
}
#[test]
fn lenient_us_pattern_partial_input() {
let pat = ParsedPattern::parse("%m/%d/%Y").unwrap();
match parse_value(&pat, "5", ParseTarget::DateOnly) {
None => {}
other => panic!("expected None (no year), got {other:?}"),
}
match parse_value(&pat, "5/2/2026", ParseTarget::DateOnly) {
Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 5, 2)),
other => panic!("expected Date, got {other:?}"),
}
}
#[test]
fn lenient_time_partial() {
let pat = ParsedPattern::parse("%H:%M:%S").unwrap();
match parse_value(&pat, "14", ParseTarget::TimeOnly) {
Some(ParsedValue::Time(t)) => assert_eq!(t, Time::new(14, 0, 0, 0).unwrap()),
other => panic!("expected Time(14:00:00), got {other:?}"),
}
match parse_value(&pat, "14:35", ParseTarget::TimeOnly) {
Some(ParsedValue::Time(t)) => assert_eq!(t, Time::new(14, 35, 0, 0).unwrap()),
other => panic!("expected Time(14:35:00), got {other:?}"),
}
}
#[test]
fn mask_for_iso_date() {
let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
assert_eq!(mask_for_pattern(&pat), "9999-99-99");
}
#[test]
fn mask_for_us_date() {
let pat = ParsedPattern::parse("%m/%d/%Y").unwrap();
assert_eq!(mask_for_pattern(&pat), "99/99/9999");
}
#[test]
fn mask_for_european_date() {
let pat = ParsedPattern::parse("%d.%m.%Y").unwrap();
assert_eq!(mask_for_pattern(&pat), "99.99.9999");
}
#[test]
fn mask_for_24h_time() {
let pat = ParsedPattern::parse("%H:%M").unwrap();
assert_eq!(mask_for_pattern(&pat), "99:99");
let pat = ParsedPattern::parse("%H:%M:%S").unwrap();
assert_eq!(mask_for_pattern(&pat), "99:99:99");
}
#[test]
fn mask_for_12h_time() {
let pat = ParsedPattern::parse("%I:%M %p").unwrap();
assert_eq!(mask_for_pattern(&pat), "99:99 >AA");
}
#[test]
fn segment_kind_max_digits() {
assert_eq!(SegmentKind::Year.max_digits(), 4);
assert_eq!(SegmentKind::Month.max_digits(), 2);
assert_eq!(SegmentKind::Day.max_digits(), 2);
assert_eq!(SegmentKind::Period.max_digits(), 0);
}
#[test]
fn segments_layout_iso_pattern() {
let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
assert_eq!(
segments_layout(&pat),
vec![
(0, 4, SegmentKind::Year),
(5, 7, SegmentKind::Month),
(8, 10, SegmentKind::Day),
]
);
}
#[test]
fn segments_layout_with_period() {
let pat = ParsedPattern::parse("%I:%M %p").unwrap();
assert_eq!(
segments_layout(&pat),
vec![
(0, 2, SegmentKind::Hour12),
(3, 5, SegmentKind::Minute),
(6, 8, SegmentKind::Period),
]
);
}
#[test]
fn segment_at_position_inside_segments() {
let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
assert_eq!(
segment_at_position(&pat, 0).map(|s| s.2),
Some(SegmentKind::Year)
);
assert_eq!(
segment_at_position(&pat, 3).map(|s| s.2),
Some(SegmentKind::Year)
);
assert_eq!(
segment_at_position(&pat, 5).map(|s| s.2),
Some(SegmentKind::Month)
);
assert_eq!(
segment_at_position(&pat, 6).map(|s| s.2),
Some(SegmentKind::Month)
);
assert_eq!(
segment_at_position(&pat, 9).map(|s| s.2),
Some(SegmentKind::Day)
);
}
#[test]
fn segment_at_position_on_separator_snaps_left() {
let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
assert_eq!(
segment_at_position(&pat, 4).map(|s| s.2),
Some(SegmentKind::Year)
);
assert_eq!(
segment_at_position(&pat, 7).map(|s| s.2),
Some(SegmentKind::Month)
);
assert_eq!(
segment_at_position(&pat, 10).map(|s| s.2),
Some(SegmentKind::Day)
);
}
#[test]
fn step_year_basic_increment() {
let d = Date::new(2026, 5, 15).unwrap();
assert_eq!(
step_date_field(d, SegmentKind::Year, 1),
Date::new(2027, 5, 15).unwrap()
);
assert_eq!(
step_date_field(d, SegmentKind::Year, -10),
Date::new(2016, 5, 15).unwrap()
);
}
#[test]
fn step_year_clamps_feb_29() {
let leap = Date::new(2024, 2, 29).unwrap();
assert_eq!(
step_date_field(leap, SegmentKind::Year, 1),
Date::new(2025, 2, 28).unwrap()
);
}
#[test]
fn step_fields_dont_overflow_on_extreme_delta() {
let d = Date::new(2026, 5, 15).unwrap();
let _ = step_date_field(d, SegmentKind::Year, i32::MAX);
let _ = step_date_field(d, SegmentKind::Year, i32::MIN);
assert_eq!(
step_date_field(d, SegmentKind::Month, i32::MAX),
step_date_field(d, SegmentKind::Month, i32::MAX.rem_euclid(12)),
);
let _ = step_date_field(d, SegmentKind::Day, i32::MIN);
let t = Time::new(10, 30, 0, 0).unwrap();
let _ = step_time_field(t, SegmentKind::Hour24, i32::MAX);
let _ = step_time_field(t, SegmentKind::Minute, i32::MIN);
let _ = step_time_field(t, SegmentKind::Second, i32::MAX);
}
#[test]
fn step_month_wraps_within_year() {
let d = Date::new(2026, 12, 5).unwrap();
assert_eq!(
step_date_field(d, SegmentKind::Month, 1),
Date::new(2026, 1, 5).unwrap()
);
let d = Date::new(2026, 1, 5).unwrap();
assert_eq!(
step_date_field(d, SegmentKind::Month, -1),
Date::new(2026, 12, 5).unwrap()
);
}
#[test]
fn step_month_clamps_day() {
let d = Date::new(2026, 3, 31).unwrap();
assert_eq!(
step_date_field(d, SegmentKind::Month, -1),
Date::new(2026, 2, 28).unwrap()
);
}
#[test]
fn step_day_wraps_within_month() {
let d = Date::new(2026, 3, 31).unwrap();
assert_eq!(
step_date_field(d, SegmentKind::Day, 1),
Date::new(2026, 3, 1).unwrap()
);
let d = Date::new(2026, 3, 1).unwrap();
assert_eq!(
step_date_field(d, SegmentKind::Day, -1),
Date::new(2026, 3, 31).unwrap()
);
}
#[test]
fn step_hour_wraps_24h() {
let t = Time::new(23, 30, 0, 0).unwrap();
assert_eq!(
step_time_field(t, SegmentKind::Hour24, 1),
Time::new(0, 30, 0, 0).unwrap()
);
let t = Time::new(0, 30, 0, 0).unwrap();
assert_eq!(
step_time_field(t, SegmentKind::Hour24, -1),
Time::new(23, 30, 0, 0).unwrap()
);
}
#[test]
fn step_minute_wraps_60() {
let t = Time::new(10, 59, 0, 0).unwrap();
assert_eq!(
step_time_field(t, SegmentKind::Minute, 1),
Time::new(10, 0, 0, 0).unwrap()
);
}
#[test]
fn step_period_toggles_am_pm() {
let am = Time::new(9, 0, 0, 0).unwrap();
assert_eq!(
step_time_field(am, SegmentKind::Period, 1),
Time::new(21, 0, 0, 0).unwrap()
);
let pm = Time::new(15, 30, 0, 0).unwrap();
assert_eq!(
step_time_field(pm, SegmentKind::Period, -1),
Time::new(3, 30, 0, 0).unwrap()
);
assert_eq!(step_time_field(am, SegmentKind::Period, 0), am);
}
}