const MONTHS_FULL: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const MONTHS_SHORT: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum StringCase {
Lower,
Upper,
Title,
Original,
}
pub fn detect_case(s: &str) -> StringCase {
if s.chars().all(|c| c.is_uppercase()) {
StringCase::Upper
} else if s.chars().all(|c| c.is_lowercase()) {
StringCase::Lower
} else {
let mut chars = s.chars();
if let Some(first) = chars.next()
&& first.is_uppercase()
&& chars.all(|c| c.is_lowercase())
{
return StringCase::Title;
}
StringCase::Original
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SimpleDate {
pub year: i32,
pub month: u32,
pub day: u32,
}
pub fn is_leap_year(year: i32) -> bool {
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}
pub fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if is_leap_year(year) {
29
} else {
28
}
}
_ => 0,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DateFormat {
Ymd {
sep: char,
},
Mdy {
sep: char,
year_len: usize,
},
Dmy {
sep: char,
year_len: usize,
},
DMmmY {
sep: char,
year_len: usize,
month_case: StringCase,
month_full: bool,
},
MmmDY {
sep: char,
year_len: usize,
month_case: StringCase,
month_full: bool,
},
YMmmD {
sep: char,
year_len: usize,
month_case: StringCase,
month_full: bool,
},
Md {
sep: char,
},
My {
sep: char,
year_len: usize,
},
DMmm {
sep: char,
month_case: StringCase,
month_full: bool,
},
MmmD {
sep: char,
month_case: StringCase,
month_full: bool,
},
MmmY {
sep: char,
year_len: usize,
month_case: StringCase,
month_full: bool,
},
YMmm {
sep: char,
year_len: usize,
month_case: StringCase,
month_full: bool,
},
}
impl DateFormat {
pub fn to_format_code(&self) -> String {
fn month_word(full: bool) -> &'static str {
if full { "mmmm" } else { "mmm" }
}
fn year(len: usize) -> &'static str {
if len == 2 { "yy" } else { "yyyy" }
}
match *self {
DateFormat::Ymd { sep } => format!("yyyy{sep}mm{sep}dd"),
DateFormat::Mdy { sep, year_len } => format!("m{sep}d{sep}{}", year(year_len)),
DateFormat::Dmy { sep, year_len } => format!("d{sep}m{sep}{}", year(year_len)),
DateFormat::DMmmY {
sep,
year_len,
month_full,
..
} => format!("d{sep}{}{sep}{}", month_word(month_full), year(year_len)),
DateFormat::MmmDY {
sep,
year_len,
month_full,
..
} => format!("{}{sep}d{sep}{}", month_word(month_full), year(year_len)),
DateFormat::YMmmD {
sep,
year_len,
month_full,
..
} => format!("{}{sep}{}{sep}d", year(year_len), month_word(month_full)),
DateFormat::Md { sep } => format!("m{sep}d"),
DateFormat::My { sep, year_len } => format!("m{sep}{}", year(year_len)),
DateFormat::DMmm {
sep, month_full, ..
} => format!("d{sep}{}", month_word(month_full)),
DateFormat::MmmD {
sep, month_full, ..
} => format!("{}{sep}d", month_word(month_full)),
DateFormat::MmmY {
sep,
year_len,
month_full,
..
} => format!("{}{sep}{}", month_word(month_full), year(year_len)),
DateFormat::YMmm {
sep,
year_len,
month_full,
..
} => format!("{}{sep}{}", year(year_len), month_word(month_full)),
}
}
pub fn month_case(&self) -> StringCase {
match *self {
DateFormat::DMmmY { month_case, .. }
| DateFormat::MmmDY { month_case, .. }
| DateFormat::YMmmD { month_case, .. }
| DateFormat::DMmm { month_case, .. }
| DateFormat::MmmD { month_case, .. }
| DateFormat::MmmY { month_case, .. }
| DateFormat::YMmm { month_case, .. } => month_case,
_ => StringCase::Title,
}
}
}
fn apply_case(s: &str, case: StringCase) -> String {
match case {
StringCase::Upper => s.to_uppercase(),
StringCase::Lower => s.to_lowercase(),
StringCase::Title | StringCase::Original => s.to_string(),
}
}
pub fn render_date_code(date: SimpleDate, code: &str, month_case: StringCase) -> String {
let chars: Vec<char> = code.chars().collect();
let mut out = String::with_capacity(code.len() + 8);
let mut i = 0;
while i < chars.len() {
let c = chars[i];
let lower = c.to_ascii_lowercase();
if !matches!(lower, 'y' | 'm' | 'd') {
out.push(c);
i += 1;
continue;
}
let mut run = 0;
while i + run < chars.len() && chars[i + run].to_ascii_lowercase() == lower {
run += 1;
}
i += run;
match lower {
'y' => {
if run <= 2 {
out.push_str(&format!("{:02}", date.year.rem_euclid(100)));
} else {
out.push_str(&format!("{:04}", date.year));
}
}
'm' => {
let idx = (date.month as usize).saturating_sub(1);
match run {
1 => out.push_str(&date.month.to_string()),
2 => out.push_str(&format!("{:02}", date.month)),
3 => out.push_str(&apply_case(
MONTHS_SHORT.get(idx).copied().unwrap_or(""),
month_case,
)),
_ => out.push_str(&apply_case(
MONTHS_FULL.get(idx).copied().unwrap_or(""),
month_case,
)),
}
}
_ => {
if run == 1 {
out.push_str(&date.day.to_string());
} else {
out.push_str(&format!("{:02}", date.day));
}
}
}
}
out
}
pub fn format_date(date: SimpleDate, format: &DateFormat) -> String {
render_date_code(date, &format.to_format_code(), format.month_case())
}
pub fn is_date_code(code: &str) -> bool {
let has_date_token = code
.chars()
.any(|c| matches!(c.to_ascii_lowercase(), 'y' | 'm' | 'd'));
let has_number_placeholder = code.contains('0') || code.contains('#');
has_date_token && !has_number_placeholder
}
pub fn excel_serial_to_date(serial: f64) -> SimpleDate {
let (year, month, day) = crate::core::date_fn::serial_to_ymd(serial);
SimpleDate {
year,
month: month.max(0) as u32,
day: day.max(0) as u32,
}
}
fn find_month_word(part: &str) -> Option<(u32, bool)> {
let p_lower = part.to_lowercase();
for (idx, &m) in MONTHS_FULL.iter().enumerate() {
if m.to_lowercase() == p_lower {
return Some((idx as u32 + 1, true));
}
}
for (idx, &m) in MONTHS_SHORT.iter().enumerate() {
if m.to_lowercase() == p_lower {
return Some((idx as u32 + 1, false));
}
}
None
}
fn parse_digits(part: &str) -> Option<i32> {
if !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()) {
part.parse::<i32>().ok()
} else {
None
}
}
pub fn parse_date(src: &str) -> Option<(SimpleDate, DateFormat)> {
const DEFAULT_YEAR: i32 = 2026;
for &sep in &['-', '/'] {
let parts: Vec<&str> = src.split(sep).collect();
if parts.len() == 3 {
let mut month_word_info = None;
for (i, part) in parts.iter().enumerate() {
if let Some((m, is_full)) = find_month_word(part) {
month_word_info = Some((i, m, is_full));
break;
}
}
if let Some((month_idx, month, is_full)) = month_word_info {
let mut digit_parts = Vec::new();
for (i, part) in parts.iter().enumerate() {
if i != month_idx
&& let Some(val) = parse_digits(part)
{
digit_parts.push((i, val, part.len()));
}
}
if digit_parts.len() == 2 {
let case = detect_case(parts[month_idx]);
if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
let day = digit_parts[0].1 as u32;
let year_raw = digit_parts[1].1;
let year_len = digit_parts[1].2;
let year = if year_len == 2 {
if year_raw < 30 {
2000 + year_raw
} else {
1900 + year_raw
}
} else {
year_raw
};
if day >= 1 && day <= days_in_month(year, month) {
return Some((
SimpleDate { year, month, day },
DateFormat::DMmmY {
sep,
year_len,
month_case: case,
month_full: is_full,
},
));
}
}
if month_idx == 0 && digit_parts[0].0 == 1 && digit_parts[1].0 == 2 {
let day = digit_parts[0].1 as u32;
let year_raw = digit_parts[1].1;
let year_len = digit_parts[1].2;
let year = if year_len == 2 {
if year_raw < 30 {
2000 + year_raw
} else {
1900 + year_raw
}
} else {
year_raw
};
if day >= 1 && day <= days_in_month(year, month) {
return Some((
SimpleDate { year, month, day },
DateFormat::MmmDY {
sep,
year_len,
month_case: case,
month_full: is_full,
},
));
}
}
if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
let year_raw = digit_parts[0].1;
let year_len = digit_parts[0].2;
let year = if year_len == 2 {
if year_raw < 30 {
2000 + year_raw
} else {
1900 + year_raw
}
} else {
year_raw
};
let day = digit_parts[1].1 as u32;
if day >= 1 && day <= days_in_month(year, month) {
return Some((
SimpleDate { year, month, day },
DateFormat::YMmmD {
sep,
year_len,
month_case: case,
month_full: is_full,
},
));
}
}
}
} else {
if let (Some(val0), Some(val1), Some(val2)) = (
parse_digits(parts[0]),
parse_digits(parts[1]),
parse_digits(parts[2]),
) {
let len0 = parts[0].len();
let len2 = parts[2].len();
if len0 == 4 {
let year = val0;
let month = val1 as u32;
let day = val2 as u32;
if (1..=12).contains(&month)
&& day >= 1
&& day <= days_in_month(year, month)
{
return Some((
SimpleDate { year, month, day },
DateFormat::Ymd { sep },
));
}
}
if len2 == 4 || len2 == 2 {
let year_raw = val2;
let year = if len2 == 2 {
if year_raw < 30 {
2000 + year_raw
} else {
1900 + year_raw
}
} else {
year_raw
};
if val0 > 12 {
let day = val0 as u32;
let month = val1 as u32;
if (1..=12).contains(&month)
&& day >= 1
&& day <= days_in_month(year, month)
{
return Some((
SimpleDate { year, month, day },
DateFormat::Dmy {
sep,
year_len: len2,
},
));
}
} else if val1 > 12 {
let month = val0 as u32;
let day = val1 as u32;
if (1..=12).contains(&month)
&& day >= 1
&& day <= days_in_month(year, month)
{
return Some((
SimpleDate { year, month, day },
DateFormat::Mdy {
sep,
year_len: len2,
},
));
}
} else {
let month = val0 as u32;
let day = val1 as u32;
if (1..=12).contains(&month)
&& day >= 1
&& day <= days_in_month(year, month)
{
return Some((
SimpleDate { year, month, day },
DateFormat::Mdy {
sep,
year_len: len2,
},
));
}
}
}
}
}
}
if parts.len() == 2 {
let mut month_word_info = None;
for (i, part) in parts.iter().enumerate() {
if let Some((m, is_full)) = find_month_word(part) {
month_word_info = Some((i, m, is_full));
break;
}
}
if let Some((month_idx, month, is_full)) = month_word_info {
let digit_idx = if month_idx == 0 { 1 } else { 0 };
if let Some(digit_val) = parse_digits(parts[digit_idx]) {
let digit_len = parts[digit_idx].len();
let case = detect_case(parts[month_idx]);
if digit_len == 4 || (digit_len == 2 && digit_val == DEFAULT_YEAR % 100) {
let year = if digit_len == 2 {
if digit_val < 30 {
2000 + digit_val
} else {
1900 + digit_val
}
} else {
digit_val
};
if (1..=12).contains(&month) {
if month_idx == 0 {
return Some((
SimpleDate {
year,
month,
day: 1,
},
DateFormat::MmmY {
sep,
year_len: digit_len,
month_case: case,
month_full: is_full,
},
));
} else {
return Some((
SimpleDate {
year,
month,
day: 1,
},
DateFormat::YMmm {
sep,
year_len: digit_len,
month_case: case,
month_full: is_full,
},
));
}
}
} else {
let day = digit_val as u32;
if day >= 1 && day <= days_in_month(DEFAULT_YEAR, month) {
if month_idx == 1 {
return Some((
SimpleDate {
year: DEFAULT_YEAR,
month,
day,
},
DateFormat::DMmm {
sep,
month_case: case,
month_full: is_full,
},
));
} else {
return Some((
SimpleDate {
year: DEFAULT_YEAR,
month,
day,
},
DateFormat::MmmD {
sep,
month_case: case,
month_full: is_full,
},
));
}
}
}
}
} else {
if let (Some(val0), Some(val1)) = (parse_digits(parts[0]), parse_digits(parts[1])) {
let len1 = parts[1].len();
if len1 == 4 {
let month = val0 as u32;
let year = val1;
if (1..=12).contains(&month) {
return Some((
SimpleDate {
year,
month,
day: 1,
},
DateFormat::My { sep, year_len: 4 },
));
}
} else {
let month = val0 as u32;
let day = val1 as u32;
if (1..=12).contains(&month)
&& day >= 1
&& day <= days_in_month(DEFAULT_YEAR, month)
{
return Some((
SimpleDate {
year: DEFAULT_YEAR,
month,
day,
},
DateFormat::Md { sep },
));
}
if (1..=12).contains(&month) && len1 == 2 {
let year = if val1 < 30 { 2000 + val1 } else { 1900 + val1 };
return Some((
SimpleDate {
year,
month,
day: 1,
},
DateFormat::My { sep, year_len: 2 },
));
}
}
}
}
}
}
None
}
pub fn date_to_excel_serial(date: SimpleDate) -> f64 {
if date.year < 1900 {
return 0.0;
}
let mut days = 0;
for y in 1900..date.year {
days += if is_leap_year(y) { 366 } else { 365 };
}
for m in 1..date.month {
days += days_in_month(date.year, m) as i32;
}
days += date.day as i32;
if date.year > 1900 || (date.year == 1900 && date.month > 2) {
days += 1;
}
days as f64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_date_parsing_and_format_detection() {
let cases: &[(&str, SimpleDate, DateFormat)] = &[
(
"2026-06-22",
SimpleDate {
year: 2026,
month: 6,
day: 22,
},
DateFormat::Ymd { sep: '-' },
),
(
"2026/06/22",
SimpleDate {
year: 2026,
month: 6,
day: 22,
},
DateFormat::Ymd { sep: '/' },
),
(
"06-22-2026",
SimpleDate {
year: 2026,
month: 6,
day: 22,
},
DateFormat::Mdy {
sep: '-',
year_len: 4,
},
),
(
"22-06-2026",
SimpleDate {
year: 2026,
month: 6,
day: 22,
},
DateFormat::Dmy {
sep: '-',
year_len: 4,
},
),
(
"06/22/26",
SimpleDate {
year: 2026,
month: 6,
day: 22,
},
DateFormat::Mdy {
sep: '/',
year_len: 2,
},
),
(
"06/22/99",
SimpleDate {
year: 1999,
month: 6,
day: 22,
},
DateFormat::Mdy {
sep: '/',
year_len: 2,
},
),
(
"22-Jun-2026",
SimpleDate {
year: 2026,
month: 6,
day: 22,
},
DateFormat::DMmmY {
sep: '-',
year_len: 4,
month_case: StringCase::Title,
month_full: false,
},
),
(
"22-June-2026",
SimpleDate {
year: 2026,
month: 6,
day: 22,
},
DateFormat::DMmmY {
sep: '-',
year_len: 4,
month_case: StringCase::Title,
month_full: true,
},
),
(
"Jun-22-2026",
SimpleDate {
year: 2026,
month: 6,
day: 22,
},
DateFormat::MmmDY {
sep: '-',
year_len: 4,
month_case: StringCase::Title,
month_full: false,
},
),
(
"6/22",
SimpleDate {
year: 2026,
month: 6,
day: 22,
},
DateFormat::Md { sep: '/' },
),
(
"22-Jun",
SimpleDate {
year: 2026,
month: 6,
day: 22,
},
DateFormat::DMmm {
sep: '-',
month_case: StringCase::Title,
month_full: false,
},
),
(
"Jun-22",
SimpleDate {
year: 2026,
month: 6,
day: 22,
},
DateFormat::MmmD {
sep: '-',
month_case: StringCase::Title,
month_full: false,
},
),
(
"6/2026",
SimpleDate {
year: 2026,
month: 6,
day: 1,
},
DateFormat::My {
sep: '/',
year_len: 4,
},
),
(
"Jun-26",
SimpleDate {
year: 2026,
month: 6,
day: 1,
},
DateFormat::MmmY {
sep: '-',
year_len: 2,
month_case: StringCase::Title,
month_full: false,
},
),
(
"2026-Jun",
SimpleDate {
year: 2026,
month: 6,
day: 1,
},
DateFormat::YMmm {
sep: '-',
year_len: 4,
month_case: StringCase::Title,
month_full: false,
},
),
];
for (src, want_date, want_format) in cases {
let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
assert_eq!(date, *want_date, "date mismatch for {src}");
assert_eq!(format, *want_format, "format mismatch for {src}");
}
}
#[test]
fn test_format_date_round_trips_the_typed_notation() {
let sources = [
"2026-06-22",
"2026/06/22",
"6/22/26",
"22-Jun-2026",
"22-June-2026",
"Jun-22-2026",
"22-Jun",
"Jun-22",
"6/2026",
"Jun-26",
"2026-Jun",
];
for src in sources {
let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
assert_eq!(
format_date(date, &format),
src,
"round trip failed for {src}"
);
}
}
#[test]
fn test_format_date_normalizes_zero_padding() {
let (date, format) = parse_date("22-06-2026").unwrap();
assert_eq!(format_date(date, &format), "22-6-2026");
let (date, format) = parse_date("06/22/2026").unwrap();
assert_eq!(format_date(date, &format), "6/22/2026");
let (date, format) = parse_date("2026-06-22").unwrap();
assert_eq!(format_date(date, &format), "2026-06-22");
}
#[test]
fn test_format_date_preserves_month_name_case() {
for src in ["22-JUN-2026", "22-jun-2026"] {
let (date, format) = parse_date(src).unwrap();
assert_eq!(format_date(date, &format), src);
}
let (date, format) = parse_date("22-JUN-2026").unwrap();
assert_eq!(format.to_format_code(), "d-mmm-yyyy");
assert_eq!(
render_date_code(date, &format.to_format_code(), StringCase::Title),
"22-Jun-2026"
);
}
#[test]
fn test_render_date_code_does_not_rescan_substituted_month_names() {
let dec = SimpleDate {
year: 2026,
month: 12,
day: 5,
};
assert_eq!(
render_date_code(dec, "mmmm d, yyyy", StringCase::Title),
"December 5, 2026"
);
let may = SimpleDate {
year: 2026,
month: 5,
day: 5,
};
assert_eq!(render_date_code(may, "mmm-yy", StringCase::Title), "May-26");
}
#[test]
fn test_render_date_code_token_widths() {
let d = SimpleDate {
year: 2026,
month: 6,
day: 7,
};
assert_eq!(
render_date_code(d, "yyyy-mm-dd", StringCase::Title),
"2026-06-07"
);
assert_eq!(render_date_code(d, "m/d/yy", StringCase::Title), "6/7/26");
assert_eq!(render_date_code(d, "mmmm", StringCase::Title), "June");
assert_eq!(
render_date_code(d, "[yyyy] week of d", StringCase::Title),
"[2026] week of 7"
);
}
#[test]
fn test_is_date_code_rejects_numeric_formats() {
assert!(is_date_code("m/d/yy"));
assert!(is_date_code("yyyy-mm-dd"));
assert!(!is_date_code("0.00"));
assert!(!is_date_code("#,##0"));
assert!(!is_date_code(""));
}
#[test]
fn test_invalid_dates_do_not_parse() {
assert!(parse_date("2026-02-30").is_none());
assert!(parse_date("2025-02-29").is_none()); assert!(parse_date("13/22/2026").is_none()); assert!(parse_date("06-32-2026").is_none()); }
#[test]
fn test_date_to_excel_serial() {
assert_eq!(
date_to_excel_serial(SimpleDate {
year: 1900,
month: 1,
day: 1
}),
1.0
);
assert_eq!(
date_to_excel_serial(SimpleDate {
year: 1900,
month: 3,
day: 1
}),
61.0
);
assert_eq!(
date_to_excel_serial(SimpleDate {
year: 2026,
month: 6,
day: 22
}),
46195.0
);
}
}