use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct SimpleDate {
pub year: i32,
pub month: u32,
pub day: u32,
}
impl SimpleDate {
fn new(year: i32, month: u32, day: u32) -> Option<Self> {
if (1..=12).contains(&month) && (1..=31).contains(&day) {
Some(SimpleDate { year, month, day })
} else {
None
}
}
}
impl fmt::Display for SimpleDate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
pub(crate) fn parse_exif_datetime(raw: &str) -> Option<SimpleDate> {
let date_part = raw.split(' ').next()?;
let mut segments = date_part.splitn(3, ':');
let year = segments.next()?.parse().ok()?;
let month = segments.next()?.parse().ok()?;
let day = segments.next()?.parse().ok()?;
SimpleDate::new(year, month, day)
}
pub(crate) fn parse_pdf_date(raw: &[u8]) -> Option<SimpleDate> {
let text = std::str::from_utf8(raw).ok()?;
let digits = text.strip_prefix("D:").unwrap_or(text);
if digits.len() < 8 || !digits.as_bytes()[..8].iter().all(u8::is_ascii_digit) {
return None;
}
let year = digits[0..4].parse().ok()?;
let month = digits[4..6].parse().ok()?;
let day = digits[6..8].parse().ok()?;
SimpleDate::new(year, month, day)
}
pub(crate) fn parse_iso_date(raw: &str) -> Option<SimpleDate> {
let date_part = raw.split('T').next()?;
let mut segments = date_part.splitn(3, '-');
let year = segments.next()?.parse().ok()?;
let month = segments.next()?.parse().ok()?;
let day = segments.next()?.parse().ok()?;
SimpleDate::new(year, month, day)
}
pub(crate) fn scan_text_for_date(text: &str) -> Option<SimpleDate> {
const MONTHS: [&str; 12] = [
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
];
for word in text.split(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '/') {
if let Some(date) = parse_iso_date(word).or_else(|| parse_us_date(word)) {
return Some(date);
}
}
let lower = text.to_ascii_lowercase();
for (index, month_name) in MONTHS.iter().enumerate() {
if let Some(pos) = lower.find(month_name) {
let tail = &text[pos + month_name.len()..];
if let Some(date) = parse_long_form_date(tail, (index + 1) as u32) {
return Some(date);
}
}
}
None
}
fn parse_us_date(word: &str) -> Option<SimpleDate> {
let mut segments = word.splitn(3, '/');
let month = segments.next()?.parse().ok()?;
let day = segments.next()?.parse().ok()?;
let year = segments.next()?.parse().ok()?;
SimpleDate::new(year, month, day)
}
fn parse_long_form_date(tail: &str, month: u32) -> Option<SimpleDate> {
let digits: String = tail
.chars()
.skip_while(|c| !c.is_ascii_digit())
.take_while(|c| c.is_ascii_digit() || *c == ',' || *c == ' ')
.collect();
let mut parts = digits
.split(|c: char| c == ',' || c.is_ascii_whitespace())
.filter(|part| !part.is_empty());
let day: u32 = parts.next()?.parse().ok()?;
let year: i32 = parts.next()?.parse().ok()?;
SimpleDate::new(year, month, day)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_exif_datetime_ignoring_time() {
assert_eq!(
parse_exif_datetime("2024:01:26 09:15:00"),
Some(SimpleDate {
year: 2024,
month: 1,
day: 26
})
);
}
#[test]
fn rejects_malformed_exif_datetime() {
assert_eq!(parse_exif_datetime("not a date"), None);
}
#[test]
fn parses_pdf_date_with_d_prefix_and_timezone() {
assert_eq!(
parse_pdf_date(b"D:20240126091500+00'00'"),
Some(SimpleDate {
year: 2024,
month: 1,
day: 26
})
);
}
#[test]
fn parses_pdf_date_without_prefix() {
assert_eq!(
parse_pdf_date(b"20240126"),
Some(SimpleDate {
year: 2024,
month: 1,
day: 26
})
);
}
#[test]
fn parses_iso_date_with_time_and_zulu() {
assert_eq!(
parse_iso_date("2024-01-26T09:15:00Z"),
Some(SimpleDate {
year: 2024,
month: 1,
day: 26
})
);
}
#[test]
fn scans_text_for_iso_date() {
assert_eq!(
scan_text_for_date("Report generated 2024-01-26 for review"),
Some(SimpleDate {
year: 2024,
month: 1,
day: 26
})
);
}
#[test]
fn scans_text_for_us_slash_date() {
assert_eq!(
scan_text_for_date("Invoice date: 01/26/2024"),
Some(SimpleDate {
year: 2024,
month: 1,
day: 26
})
);
}
#[test]
fn scans_text_for_long_form_date() {
assert_eq!(
scan_text_for_date("Signed on January 26, 2024 by both parties"),
Some(SimpleDate {
year: 2024,
month: 1,
day: 26
})
);
}
#[test]
fn scan_text_returns_none_when_nothing_looks_like_a_date() {
assert_eq!(scan_text_for_date("no dates anywhere in this text"), None);
}
#[test]
fn display_formats_as_y_m_d() {
assert_eq!(
SimpleDate {
year: 2024,
month: 1,
day: 26
}
.to_string(),
"2024-01-26"
);
}
}