use chrono::{Datelike, NaiveDate, Weekday};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParsedDate {
pub weekday: Weekday,
pub month: u32,
pub day: u32,
pub year: i32,
}
impl ParsedDate {
pub fn from_iso(date_str: &str) -> Option<Self> {
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok()?;
Some(Self {
weekday: date.weekday(),
month: date.month(),
day: date.day(),
year: date.year(),
})
}
}
pub fn format_hour(time_str: &str, military_time: bool) -> String {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(time_str) {
return format_chrono_time(&dt, military_time);
}
if let Some(hour) = time_str
.split('T')
.nth(1)
.and_then(|t| t.split(':').next()?.parse::<u32>().ok())
{
return format_hour_minute(hour, 0, military_time);
}
time_str.to_string()
}
pub fn format_time(time_str: &str, military_time: bool) -> String {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(time_str) {
return format_chrono_time(&dt, military_time);
}
if let Some(time_part) = time_str.split('T').nth(1) {
let parts: Vec<&str> = time_part.split(':').collect();
if let (Some(Ok(hour)), Some(Ok(minute))) = (
parts.first().map(|s| s.parse::<u32>()),
parts.get(1).map(|s| s.parse::<u32>()),
) {
return format_hour_minute(hour, minute, military_time);
}
}
time_str.to_string()
}
pub fn is_night_time(sunrise: &str, sunset: &str, utc_offset_seconds: i32) -> bool {
use chrono::{Duration, NaiveDateTime, Timelike, Utc};
let now = Utc::now().naive_utc() + Duration::seconds(utc_offset_seconds as i64);
let parse_time = |time_str: &str| -> Option<NaiveDateTime> {
NaiveDateTime::parse_from_str(time_str, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(time_str, "%Y-%m-%dT%H:%M"))
.ok()
};
match (parse_time(sunrise), parse_time(sunset)) {
(Some(sunrise_time), Some(sunset_time)) => now < sunrise_time || now > sunset_time,
_ => {
let hour = now.hour();
!(6..18).contains(&hour)
}
}
}
fn format_chrono_time<Tz: chrono::TimeZone>(
dt: &chrono::DateTime<Tz>,
military_time: bool,
) -> String
where
Tz::Offset: std::fmt::Display,
{
if military_time {
dt.format("%H:%M").to_string()
} else {
let formatted = dt.format("%I:%M %p").to_string();
formatted.trim_start_matches('0').to_string()
}
}
fn format_hour_minute(hour: u32, minute: u32, military_time: bool) -> String {
if military_time {
format!("{:02}:{:02}", hour, minute)
} else {
let (display_hour, period) = match hour {
0 => (12, "AM"),
1..=11 => (hour, "AM"),
12 => (12, "PM"),
_ => (hour - 12, "PM"),
};
format!("{}:{:02} {}", display_hour, minute, period)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsed_date_from_iso() {
let d = ParsedDate::from_iso("2025-11-25")
.expect("parsed_date_from_iso: ISO date \"2025-11-25\" should parse");
assert_eq!(d.year, 2025);
assert_eq!(d.month, 11);
assert_eq!(d.day, 25);
assert!(ParsedDate::from_iso("not-a-date").is_none());
}
#[test]
fn format_hour_handles_rfc3339_and_naive() {
assert_eq!(format_hour("2025-01-20T14:00:00+09:00", true), "14:00");
assert_eq!(format_hour("2025-01-20T14:00", true), "14:00");
assert_eq!(format_hour("2025-01-20T00:00", false), "12:00 AM");
}
#[test]
fn night_when_now_is_outside_the_window() {
assert!(is_night_time(
"1970-01-01T06:00:00",
"1970-01-01T18:00:00",
0
));
assert!(is_night_time(
"2999-01-01T06:00:00",
"2999-01-01T18:00:00",
9 * 3600
));
}
#[test]
fn day_when_now_is_inside_the_window() {
assert!(!is_night_time(
"1970-01-01T00:00:00",
"2999-12-31T23:59:59",
-5 * 3600
));
}
#[test]
fn format_time_rfc3339_military() {
assert_eq!(format_time("2025-01-20T06:30:45+09:00", true), "06:30");
}
#[test]
fn format_time_rfc3339_12h_trims_leading_zero() {
assert_eq!(format_time("2025-01-20T06:30:00+09:00", false), "6:30 AM");
}
#[test]
fn format_time_naive_military() {
assert_eq!(format_time("2025-01-20T14:30:00", true), "14:30");
}
#[test]
fn format_time_naive_12h() {
assert_eq!(format_time("2025-01-20T14:30:00", false), "2:30 PM");
}
#[test]
fn format_time_unparseable_returns_input() {
assert_eq!(format_time("garbage", true), "garbage");
assert_eq!(format_time("2025-01-20", true), "2025-01-20");
}
#[test]
fn format_hour_minute_boundary_12_pm_and_pm_arm() {
assert_eq!(format_time("2025-01-20T12:00:00", false), "12:00 PM");
assert_eq!(format_time("2025-01-20T13:15:00", false), "1:15 PM");
assert_eq!(format_time("2025-01-20T23:59:00", false), "11:59 PM");
assert_eq!(format_time("2025-01-20T00:15:00", false), "12:15 AM");
}
#[test]
fn format_hour_minute_military_passthrough_pads_zeros() {
assert_eq!(format_time("2025-01-20T05:07:00", true), "05:07");
}
#[test]
fn is_night_time_unparseable_fallback_returns_false_in_day_bucket() {
use chrono::Timelike;
let now_utc_hour = chrono::Utc::now().naive_utc().hour() as i64;
let target_hour = 12i64;
let offset_hours = (target_hour - now_utc_hour).rem_euclid(24);
let offset_seconds = (offset_hours * 3600) as i32;
assert!(!is_night_time(
"garbage-no-T-separator",
"also-garbage",
offset_seconds
));
}
#[test]
fn is_night_time_unparseable_fallback_returns_true_in_night_bucket() {
use chrono::Timelike;
let now_utc_hour = chrono::Utc::now().naive_utc().hour() as i64;
let target_hour = 22i64;
let offset_hours = (target_hour - now_utc_hour).rem_euclid(24);
let offset_seconds = (offset_hours * 3600) as i32;
assert!(is_night_time(
"garbage-no-T-separator",
"also-garbage",
offset_seconds
));
}
#[test]
fn is_night_time_partial_parse_falls_through_to_fallback() {
use chrono::Timelike;
let now_utc_hour = chrono::Utc::now().naive_utc().hour() as i64;
let target_hour = 12i64;
let offset_hours = (target_hour - now_utc_hour).rem_euclid(24);
let day_offset_seconds = (offset_hours * 3600) as i32;
assert!(!is_night_time(
"2025-01-20T06:00:00",
"garbage-end",
day_offset_seconds
));
assert!(!is_night_time(
"garbage-start",
"2025-01-20T18:00:00",
day_offset_seconds
));
}
#[test]
fn format_hour_unparseable_returns_input() {
assert_eq!(
format_hour("garbage-no-T-separator", true),
"garbage-no-T-separator"
);
assert_eq!(format_hour("2025-01-20Tabc", true), "2025-01-20Tabc");
assert_eq!(format_hour("2025-01-20T:00", true), "2025-01-20T:00");
}
}