use core::fmt;
use super::stata_error::{Result, StataError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StataTimestamp {
day: u8,
month: u8,
year: u16,
hour: u8,
minute: u8,
}
impl StataTimestamp {
pub fn parse(s: &str) -> Result<Self> {
let mut parts = s.split_whitespace();
let day = parts.next().ok_or(StataError::InvalidTimestamp)?;
let day: u8 = parse_day(day)?;
let month = parts.next().ok_or(StataError::InvalidTimestamp)?;
let month = parse_month(month)?;
let year = parts.next().ok_or(StataError::InvalidTimestamp)?;
let year: u16 = next_int(year)?;
let time = parts.next().ok_or(StataError::InvalidTimestamp)?;
let (hour, minute) = parse_time(time)?;
if parts.next().is_some() {
return Err(StataError::InvalidTimestamp);
}
let timestamp = Self {
day,
month,
year,
hour,
minute,
};
Ok(timestamp)
}
#[must_use]
#[inline]
pub fn day(&self) -> u8 {
self.day
}
#[must_use]
#[inline]
pub fn month(&self) -> u8 {
self.month
}
#[must_use]
#[inline]
pub fn year(&self) -> u16 {
self.year
}
#[must_use]
#[inline]
pub fn hour(&self) -> u8 {
self.hour
}
#[must_use]
#[inline]
pub fn minute(&self) -> u8 {
self.minute
}
}
#[cfg(feature = "chrono")]
impl StataTimestamp {
#[must_use]
pub fn to_naive_date_time(&self) -> Option<chrono::NaiveDateTime> {
chrono::NaiveDate::from_ymd_opt(
i32::from(self.year),
u32::from(self.month),
u32::from(self.day),
)?
.and_hms_opt(u32::from(self.hour), u32::from(self.minute), 0)
}
}
fn parse_month(s: &str) -> Result<u8> {
let bytes = s.as_bytes();
if bytes.len() != 3 {
return Err(StataError::InvalidTimestamp);
}
let lower = [
bytes[0].to_ascii_lowercase(),
bytes[1].to_ascii_lowercase(),
bytes[2].to_ascii_lowercase(),
];
match &lower {
b"jan" | b"ene" => Ok(1),
b"feb" => Ok(2),
b"mar" => Ok(3),
b"apr" | b"abr" => Ok(4),
b"may" | b"mai" => Ok(5),
b"jun" => Ok(6),
b"jul" => Ok(7),
b"aug" | b"ago" => Ok(8),
b"sep" => Ok(9),
b"oct" | b"okt" => Ok(10),
b"nov" => Ok(11),
b"dec" | b"dez" | b"dic" => Ok(12),
_ => Err(StataError::InvalidTimestamp),
}
}
fn parse_time(s: &str) -> Result<(u8, u8)> {
let (h, m) = s.split_once(':').ok_or(StataError::InvalidTimestamp)?;
let hour: u8 = h.parse().map_err(|_| StataError::InvalidTimestamp)?;
let minute: u8 = m.parse().map_err(|_| StataError::InvalidTimestamp)?;
if hour > 23 || minute > 59 {
return Err(StataError::InvalidTimestamp);
}
Ok((hour, minute))
}
fn parse_day(value: &str) -> Result<u8> {
let day: u8 = next_int(value)?;
if !(1..=31).contains(&day) {
return Err(StataError::InvalidTimestamp);
}
Ok(day)
}
fn next_int<T: core::str::FromStr>(value: &str) -> Result<T> {
value.parse().map_err(|_| StataError::InvalidTimestamp)
}
impl fmt::Display for StataTimestamp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let month_name = MONTHS
.get((self.month.wrapping_sub(1)) as usize)
.unwrap_or(&"???");
write!(
f,
"{:02} {} {:04} {:02}:{:02}",
self.day, month_name, self.year, self.hour, self.minute
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stata::stata_error::StataError;
#[test]
fn parse_typical() {
let ts = StataTimestamp::parse("01 Jan 2024 13:45").unwrap();
assert_eq!(ts.day(), 1);
assert_eq!(ts.month(), 1);
assert_eq!(ts.year(), 2024);
assert_eq!(ts.hour(), 13);
assert_eq!(ts.minute(), 45);
}
#[test]
fn parse_single_digit_day() {
let ts = StataTimestamp::parse("5 Mar 2023 09:00").unwrap();
assert_eq!(ts.day(), 5);
assert_eq!(ts.month(), 3);
}
#[test]
fn parse_leading_space() {
let ts = StataTimestamp::parse(" 5 Jan 2024 14:30").unwrap();
assert_eq!(ts.day(), 5);
assert_eq!(ts.hour(), 14);
assert_eq!(ts.minute(), 30);
}
#[test]
fn parse_extra_spaces_before_time() {
let ts = StataTimestamp::parse("12 Dec 2023 09:00").unwrap();
assert_eq!(ts.day(), 12);
assert_eq!(ts.month(), 12);
assert_eq!(ts.hour(), 9);
}
#[test]
fn parse_case_insensitive_month() {
let ts = StataTimestamp::parse("15 JAN 2020 00:00").unwrap();
assert_eq!(ts.month(), 1);
let ts = StataTimestamp::parse("15 jan 2020 00:00").unwrap();
assert_eq!(ts.month(), 1);
}
#[test]
fn parse_localised_months() {
assert_eq!(
StataTimestamp::parse("01 Ene 2020 00:00").unwrap().month(),
1
);
assert_eq!(
StataTimestamp::parse("01 Abr 2020 00:00").unwrap().month(),
4
);
assert_eq!(
StataTimestamp::parse("01 Ago 2020 00:00").unwrap().month(),
8
);
assert_eq!(
StataTimestamp::parse("01 Dic 2020 00:00").unwrap().month(),
12
);
assert_eq!(
StataTimestamp::parse("01 Okt 2020 00:00").unwrap().month(),
10
);
assert_eq!(
StataTimestamp::parse("01 Dez 2020 00:00").unwrap().month(),
12
);
assert_eq!(
StataTimestamp::parse("01 Mai 2020 00:00").unwrap().month(),
5
);
}
#[test]
fn parse_roundtrip_through_display() {
let ts = StataTimestamp::parse("07 Sep 2019 23:59").unwrap();
let formatted = ts.to_string();
let ts2 = StataTimestamp::parse(&formatted).unwrap();
assert_eq!(ts, ts2);
}
#[test]
fn parse_empty_string() {
assert_eq!(StataTimestamp::parse(""), Err(StataError::InvalidTimestamp),);
}
#[test]
fn parse_missing_time() {
assert_eq!(
StataTimestamp::parse("01 Jan 2024"),
Err(StataError::InvalidTimestamp),
);
}
#[test]
fn parse_extra_token() {
assert_eq!(
StataTimestamp::parse("01 Jan 2024 13:45 extra"),
Err(StataError::InvalidTimestamp),
);
}
#[test]
fn parse_invalid_month() {
assert_eq!(
StataTimestamp::parse("01 Xyz 2024 13:45"),
Err(StataError::InvalidTimestamp),
);
}
#[test]
fn parse_day_zero() {
assert_eq!(
StataTimestamp::parse("00 Jan 2024 13:45"),
Err(StataError::InvalidTimestamp),
);
}
#[test]
fn parse_hour_24() {
assert_eq!(
StataTimestamp::parse("01 Jan 2024 24:00"),
Err(StataError::InvalidTimestamp),
);
}
#[test]
fn parse_minute_60() {
assert_eq!(
StataTimestamp::parse("01 Jan 2024 13:60"),
Err(StataError::InvalidTimestamp),
);
}
#[test]
fn parse_bad_time_separator() {
assert_eq!(
StataTimestamp::parse("01 Jan 2024 13-45"),
Err(StataError::InvalidTimestamp),
);
}
}
#[cfg(all(test, feature = "chrono"))]
mod chrono_tests {
use super::*;
use chrono::NaiveDate;
#[test]
fn typical_converts() {
let ts = StataTimestamp::parse("15 Mar 2023 09:30").unwrap();
assert_eq!(
ts.to_naive_date_time(),
NaiveDate::from_ymd_opt(2023, 3, 15).and_then(|d| d.and_hms_opt(9, 30, 0)),
);
}
#[test]
fn leap_day_in_leap_year_converts() {
let ts = StataTimestamp::parse("29 Feb 2024 12:00").unwrap();
assert_eq!(
ts.to_naive_date_time(),
NaiveDate::from_ymd_opt(2024, 2, 29).and_then(|d| d.and_hms_opt(12, 0, 0)),
);
}
#[test]
fn invalid_calendar_date_returns_none() {
let ts = StataTimestamp::parse("31 Feb 2024 12:00").unwrap();
assert_eq!(ts.to_naive_date_time(), None);
}
#[test]
fn feb_29_in_non_leap_year_returns_none() {
let ts = StataTimestamp::parse("29 Feb 2023 12:00").unwrap();
assert_eq!(ts.to_naive_date_time(), None);
}
#[test]
fn seconds_are_always_zero() {
let ts = StataTimestamp::parse("01 Jan 2024 23:59").unwrap();
let dt = ts.to_naive_date_time().unwrap();
assert_eq!(dt.format("%S").to_string(), "00");
}
}