use crate::types::{ColumnStats, DateTimeStats, SlashDateOrder};
use chrono::{Datelike, NaiveDate, NaiveDateTime, Timelike, Weekday};
use std::collections::HashMap;
struct ParsedDateTime {
date: NaiveDate,
datetime: Option<NaiveDateTime>,
}
const SLASH_DATETIME_DAY_FIRST: &str = "%d/%m/%Y %H:%M:%S";
const SLASH_DATETIME_MONTH_FIRST: &str = "%m/%d/%Y %H:%M:%S";
const SLASH_DATE_DAY_FIRST: &str = "%d/%m/%Y";
const SLASH_DATE_MONTH_FIRST: &str = "%m/%d/%Y";
fn parses_slash(value: &str, format_date: &str, format_datetime: &str) -> bool {
NaiveDate::parse_from_str(value, format_date).is_ok()
|| NaiveDateTime::parse_from_str(value, format_datetime).is_ok()
}
pub fn resolve_slash_order<S: AsRef<str>>(values: &[S]) -> Option<SlashDateOrder> {
let mut slash_dates = 0usize;
let mut day_only = 0usize;
let mut month_only = 0usize;
for value in values {
let trimmed = value.as_ref().trim();
if !trimmed.contains('/') {
continue;
}
let day_first = parses_slash(trimmed, SLASH_DATE_DAY_FIRST, SLASH_DATETIME_DAY_FIRST);
let month_first = parses_slash(trimmed, SLASH_DATE_MONTH_FIRST, SLASH_DATETIME_MONTH_FIRST);
match (day_first, month_first) {
(true, true) => slash_dates += 1,
(true, false) => {
slash_dates += 1;
day_only += 1;
}
(false, true) => {
slash_dates += 1;
month_only += 1;
}
(false, false) => {}
}
}
match (slash_dates, day_only, month_only) {
(0, _, _) => None,
(_, 0, 0) => Some(SlashDateOrder::AssumedDayFirst),
(_, _, 0) => Some(SlashDateOrder::DayFirst),
(_, 0, _) => Some(SlashDateOrder::MonthFirst),
_ => Some(SlashDateOrder::Mixed),
}
}
fn reads_month_first(order: Option<SlashDateOrder>) -> bool {
order == Some(SlashDateOrder::MonthFirst)
}
pub fn calculate_datetime_stats(data: &[String]) -> ColumnStats {
ColumnStats::DateTime(compute_datetime_stats(data))
}
pub fn compute_datetime_stats(data: &[String]) -> DateTimeStats {
let slash_date_order = resolve_slash_order(data);
let month_first = reads_month_first(slash_date_order);
let parsed: Vec<ParsedDateTime> = data
.iter()
.filter_map(|s| parse_flexible_full(s, month_first))
.collect();
if parsed.is_empty() {
return DateTimeStats::empty();
}
let dates: Vec<NaiveDate> = parsed.iter().map(|p| p.date).collect();
let min_date = dates.iter().min().unwrap();
let max_date = dates.iter().max().unwrap();
let duration_days = (*max_date - *min_date).num_days() as f64;
let year_distribution = build_year_distribution(&dates);
let month_distribution = build_month_distribution(&dates);
let day_of_week_distribution = build_day_of_week_distribution(&dates);
let datetimes: Vec<NaiveDateTime> = parsed.iter().filter_map(|p| p.datetime).collect();
let hour_distribution = if datetimes.is_empty() {
None
} else {
Some(build_hour_distribution(&datetimes))
};
DateTimeStats {
min_datetime: min_date.format("%Y-%m-%d").to_string(),
max_datetime: max_date.format("%Y-%m-%d").to_string(),
duration_days,
year_distribution,
month_distribution,
day_of_week_distribution,
hour_distribution,
slash_date_order,
}
}
fn parse_flexible_full(s: &str, month_first: bool) -> Option<ParsedDateTime> {
let trimmed = s.trim();
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
let utc = dt.naive_utc();
return Some(ParsedDateTime {
date: utc.date(),
datetime: Some(utc),
});
}
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
return Some(ParsedDateTime {
date: dt.date(),
datetime: Some(dt),
});
}
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S") {
return Some(ParsedDateTime {
date: dt.date(),
datetime: Some(dt),
});
}
let (slash_datetimes, slash_dates) = if month_first {
(
[SLASH_DATETIME_MONTH_FIRST, SLASH_DATETIME_DAY_FIRST],
[SLASH_DATE_MONTH_FIRST, SLASH_DATE_DAY_FIRST],
)
} else {
(
[SLASH_DATETIME_DAY_FIRST, SLASH_DATETIME_MONTH_FIRST],
[SLASH_DATE_DAY_FIRST, SLASH_DATE_MONTH_FIRST],
)
};
for format in slash_datetimes {
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, format) {
return Some(ParsedDateTime {
date: dt.date(),
datetime: Some(dt),
});
}
}
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f") {
return Some(ParsedDateTime {
date: dt.date(),
datetime: Some(dt),
});
}
let date_formats = [
"%Y-%m-%d", slash_dates[0], "%d-%m-%Y", "%d.%m.%Y", "%Y/%m/%d", slash_dates[1], ];
for format in date_formats {
if let Ok(date) = NaiveDate::parse_from_str(trimmed, format) {
return Some(ParsedDateTime {
date,
datetime: None,
});
}
}
None
}
pub(crate) fn parse_raw_datetime_date(s: &str, order: Option<SlashDateOrder>) -> Option<NaiveDate> {
if !looks_like_raw_datetime_candidate(s) {
return None;
}
parse_flexible_full(s, reads_month_first(order)).map(|parsed| parsed.date)
}
pub(crate) fn parse_raw_datetime_year(s: &str) -> Option<i32> {
parse_raw_datetime_date(s, None).map(|date| date.year())
}
fn looks_like_raw_datetime_candidate(s: &str) -> bool {
if s != s.trim() || s.len() < 10 {
return false;
}
let bytes = s.as_bytes();
let ascii_digits = |range: std::ops::Range<usize>| {
bytes
.get(range)
.is_some_and(|slice| slice.iter().all(u8::is_ascii_digit))
};
let supported_separator = |byte: u8| matches!(byte, b'-' | b'/' | b'.');
let year_first = ascii_digits(0..4)
&& bytes.get(4).is_some_and(|byte| supported_separator(*byte))
&& bytes.get(7) == bytes.get(4);
let year_last = ascii_digits(6..10)
&& bytes.get(2).is_some_and(|byte| supported_separator(*byte))
&& bytes.get(5) == bytes.get(2);
year_first || year_last
}
fn build_year_distribution(dates: &[NaiveDate]) -> HashMap<i32, usize> {
let mut dist = HashMap::new();
for date in dates {
*dist.entry(date.year()).or_insert(0) += 1;
}
dist
}
fn build_month_distribution(dates: &[NaiveDate]) -> HashMap<u32, usize> {
let mut dist = HashMap::new();
for date in dates {
*dist.entry(date.month()).or_insert(0) += 1;
}
dist
}
fn build_day_of_week_distribution(dates: &[NaiveDate]) -> HashMap<String, usize> {
let mut dist = HashMap::new();
for date in dates {
let day_name = weekday_name(date.weekday());
*dist.entry(day_name.to_string()).or_insert(0) += 1;
}
dist
}
fn weekday_name(weekday: Weekday) -> &'static str {
match weekday {
Weekday::Mon => "Monday",
Weekday::Tue => "Tuesday",
Weekday::Wed => "Wednesday",
Weekday::Thu => "Thursday",
Weekday::Fri => "Friday",
Weekday::Sat => "Saturday",
Weekday::Sun => "Sunday",
}
}
fn build_hour_distribution(datetimes: &[NaiveDateTime]) -> HashMap<u32, usize> {
let mut dist = HashMap::new();
for dt in datetimes {
*dist.entry(dt.hour()).or_insert(0) += 1;
}
dist
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_iso_date() {
let parsed = parse_flexible_full("2023-01-15", false).unwrap();
assert_eq!(parsed.date.year(), 2023);
assert_eq!(parsed.date.month(), 1);
assert_eq!(parsed.date.day(), 15);
assert!(parsed.datetime.is_none());
}
#[test]
fn test_parse_european_format() {
let parsed = parse_flexible_full("15/01/2023", false).unwrap();
assert_eq!(parsed.date.day(), 15);
assert_eq!(parsed.date.month(), 1);
assert_eq!(parsed.date.year(), 2023);
assert!(parsed.datetime.is_none());
}
#[test]
fn test_parse_us_format() {
let parsed = parse_flexible_full("01/15/2023", false).unwrap();
assert_eq!(parsed.date.month(), 1);
assert_eq!(parsed.date.day(), 15);
assert!(parsed.datetime.is_none());
}
#[test]
fn test_parse_datetime_iso() {
let parsed = parse_flexible_full("2023-01-15T10:30:00", false).unwrap();
assert_eq!(parsed.date.year(), 2023);
assert!(parsed.datetime.is_some());
let dt = parsed.datetime.unwrap();
assert_eq!(dt.hour(), 10);
assert_eq!(dt.minute(), 30);
}
#[test]
fn raw_datetime_year_validates_the_complete_calendar_value() {
assert_eq!(parse_raw_datetime_year("2024-02-29"), Some(2024));
assert_eq!(parse_raw_datetime_year("2024-02-29T10:30:00Z"), Some(2024));
assert_eq!(parse_raw_datetime_year("1800-01-01"), Some(1800));
assert_eq!(parse_raw_datetime_year("2200-01-01"), Some(2200));
assert_eq!(parse_raw_datetime_year("2024-13-45"), None);
assert_eq!(parse_raw_datetime_year("2023-02-29"), None);
assert_eq!(parse_raw_datetime_year(" 2024-02-29"), None);
assert_eq!(parse_raw_datetime_year("ordinary text value"), None);
}
#[test]
fn test_year_distribution() {
let dates = vec![
NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2023, 6, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
];
let dist = build_year_distribution(&dates);
assert_eq!(dist.get(&2023), Some(&2));
assert_eq!(dist.get(&2024), Some(&1));
}
#[test]
fn test_month_distribution() {
let dates = vec![
NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(),
NaiveDate::from_ymd_opt(2023, 2, 1).unwrap(),
];
let dist = build_month_distribution(&dates);
assert_eq!(dist.get(&1), Some(&2));
assert_eq!(dist.get(&2), Some(&1));
}
#[test]
fn test_day_of_week_distribution() {
let dates = vec![
NaiveDate::from_ymd_opt(2023, 1, 2).unwrap(), NaiveDate::from_ymd_opt(2023, 1, 3).unwrap(), NaiveDate::from_ymd_opt(2023, 1, 9).unwrap(), ];
let dist = build_day_of_week_distribution(&dates);
assert_eq!(dist.get("Monday"), Some(&2));
assert_eq!(dist.get("Tuesday"), Some(&1));
}
#[test]
fn test_duration_calculation() {
let data = vec!["2023-01-01".to_string(), "2023-01-31".to_string()];
let stats = calculate_datetime_stats(&data);
match stats {
ColumnStats::DateTime(d) => {
assert_eq!(d.duration_days, 30.0);
}
_ => panic!("Expected DateTime stats"),
}
}
#[test]
fn test_hour_distribution() {
let data = vec![
"2023-01-01T10:00:00".to_string(),
"2023-01-01T10:30:00".to_string(),
"2023-01-01T14:00:00".to_string(),
];
let stats = calculate_datetime_stats(&data);
match stats {
ColumnStats::DateTime(d) => {
let dist = d.hour_distribution.unwrap();
assert_eq!(dist.get(&10), Some(&2));
assert_eq!(dist.get(&14), Some(&1));
}
_ => panic!("Expected DateTime stats"),
}
}
fn values(items: &[&str]) -> Vec<String> {
items.iter().map(|item| (*item).to_string()).collect()
}
#[test]
fn the_column_decides_the_slash_order() {
for (column, expected) in [
(
&["12/31/2024", "01/02/2024"][..],
Some(SlashDateOrder::MonthFirst),
),
(
&["31/12/2024", "01/02/2024"][..],
Some(SlashDateOrder::DayFirst),
),
(
&["01/02/2024", "03/04/2024"][..],
Some(SlashDateOrder::AssumedDayFirst),
),
(
&["12/31/2024", "31/12/2024"][..],
Some(SlashDateOrder::Mixed),
),
(
&["12/31/2024 10:00:00", "01/02/2024 09:00:00"][..],
Some(SlashDateOrder::MonthFirst),
),
(&["2024-01-15", "15.01.2024", "", "junk"][..], None),
(
&["2024-01-15", "12/31/2024", "junk"][..],
Some(SlashDateOrder::MonthFirst),
),
] {
assert_eq!(resolve_slash_order(column), expected, "{column:?}");
}
}
#[test]
fn a_month_first_column_reads_its_ambiguous_dates_month_first() {
let data = values(&[
"12/31/2024",
"01/02/2024",
"03/04/2024",
"12/30/2024",
"05/06/2024",
"11/29/2024",
]);
let stats = compute_datetime_stats(&data);
assert_eq!(stats.slash_date_order, Some(SlashDateOrder::MonthFirst));
assert_eq!(stats.min_datetime, "2024-01-02");
assert_eq!(stats.max_datetime, "2024-12-31");
assert_eq!(
stats.month_distribution,
HashMap::from([(12, 2), (1, 1), (3, 1), (5, 1), (11, 1)])
);
}
#[test]
fn a_day_first_column_is_read_as_before() {
let data = values(&["31/12/2024", "01/02/2024", "03/04/2024"]);
let stats = compute_datetime_stats(&data);
assert_eq!(stats.slash_date_order, Some(SlashDateOrder::DayFirst));
assert_eq!(stats.min_datetime, "2024-02-01");
assert_eq!(
stats.month_distribution,
HashMap::from([(12, 1), (2, 1), (4, 1)])
);
}
#[test]
fn a_month_first_datetime_column_parses_every_value() {
let data = values(&["12/31/2024 10:00:00", "01/02/2024 09:30:00"]);
let stats = compute_datetime_stats(&data);
assert_eq!(stats.slash_date_order, Some(SlashDateOrder::MonthFirst));
assert_eq!(stats.min_datetime, "2024-01-02");
assert_eq!(stats.max_datetime, "2024-12-31");
assert_eq!(
stats.hour_distribution,
Some(HashMap::from([(10, 1), (9, 1)]))
);
}
#[test]
fn a_column_without_slash_dates_records_no_order() {
let data = values(&["2024-01-15", "15.01.2024"]);
assert_eq!(compute_datetime_stats(&data).slash_date_order, None);
}
#[test]
fn quality_parsing_follows_the_column_order() {
let month_first = Some(SlashDateOrder::MonthFirst);
assert_eq!(
parse_raw_datetime_date("01/02/2024", month_first),
NaiveDate::from_ymd_opt(2024, 1, 2)
);
for order in [
None,
Some(SlashDateOrder::DayFirst),
Some(SlashDateOrder::AssumedDayFirst),
Some(SlashDateOrder::Mixed),
] {
assert_eq!(
parse_raw_datetime_date("01/02/2024", order),
NaiveDate::from_ymd_opt(2024, 2, 1),
"{order:?}"
);
}
assert_eq!(
parse_raw_datetime_date("12/31/2024", None),
NaiveDate::from_ymd_opt(2024, 12, 31)
);
}
#[test]
fn test_empty_data() {
let data: Vec<String> = vec![];
let stats = calculate_datetime_stats(&data);
match stats {
ColumnStats::DateTime(d) => {
assert!(d.min_datetime.is_empty());
assert!(d.max_datetime.is_empty());
assert_eq!(d.duration_days, 0.0);
}
_ => panic!("Expected DateTime stats"),
}
}
}