timerd-rs 0.4.0

A small foreground/background terminal timer for Linux
use std::fmt::Display;

use anyhow::{Result, bail};
use chrono::{DateTime, Local, LocalResult, NaiveDateTime, NaiveTime, TimeZone, Timelike};

const EXPECTED_FORMATS: &str = "HH:MM, HH:MM:SS, YYYY-MM-DD HH:MM, or YYYY-MM-DD HH:MM:SS";

pub fn parse_alarm_target(value: &str) -> Result<u64> {
    let target = parse_in_timezone(value, Local::now())?;
    u64::try_from(target.timestamp_millis())
        .map_err(|_| anyhow::anyhow!("alarm time is outside the supported range"))
}

pub fn format_alarm_target(target_millis: u64) -> String {
    format_target(target_millis, true)
}

pub fn format_alarm_clock(target_millis: u64) -> String {
    format_target(target_millis, false)
}

fn format_target(target_millis: u64, include_date: bool) -> String {
    let Ok(timestamp) = i64::try_from(target_millis) else {
        return "unknown time".to_owned();
    };
    let Some(target) = Local.timestamp_millis_opt(timestamp).single() else {
        return "unknown time".to_owned();
    };
    match (include_date, target.second()) {
        (true, 0) => target.format("%Y-%m-%d %H:%M").to_string(),
        (true, _) => target.format("%Y-%m-%d %H:%M:%S").to_string(),
        (false, 0) => target.format("%H:%M").to_string(),
        (false, _) => target.format("%H:%M:%S").to_string(),
    }
}

fn parse_in_timezone<Tz>(value: &str, now: DateTime<Tz>) -> Result<DateTime<Tz>>
where
    Tz: TimeZone,
    Tz::Offset: Display,
{
    let value = value.trim();
    if value.is_empty() {
        bail!("alarm time is empty; expected {EXPECTED_FORMATS}");
    }

    if let Some(time) = parse_time(value) {
        let mut date = now.date_naive();
        let mut local = date.and_time(time);
        if local <= now.naive_local() {
            date = date
                .succ_opt()
                .ok_or_else(|| anyhow::anyhow!("alarm date is outside the supported range"))?;
            local = date.and_time(time);
        }
        return resolve_local(now.timezone(), local, now.timestamp_millis());
    }

    if let Some(local) = parse_date_time(value) {
        let target = resolve_local(now.timezone(), local, now.timestamp_millis())?;
        if target.timestamp_millis() <= now.timestamp_millis() {
            bail!("alarm time `{value}` has already passed");
        }
        return Ok(target);
    }

    bail!("invalid alarm time `{value}`; expected {EXPECTED_FORMATS}")
}

fn parse_time(value: &str) -> Option<NaiveTime> {
    ["%H:%M:%S", "%H:%M"]
        .into_iter()
        .find_map(|format| NaiveTime::parse_from_str(value, format).ok())
}

fn parse_date_time(value: &str) -> Option<NaiveDateTime> {
    ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"]
        .into_iter()
        .find_map(|format| NaiveDateTime::parse_from_str(value, format).ok())
}

fn resolve_local<Tz>(timezone: Tz, local: NaiveDateTime, now_millis: i64) -> Result<DateTime<Tz>>
where
    Tz: TimeZone,
    Tz::Offset: Display,
{
    match timezone.from_local_datetime(&local) {
        LocalResult::Single(target) => Ok(target),
        LocalResult::Ambiguous(first, second) => [first, second]
            .into_iter()
            .filter(|target| target.timestamp_millis() > now_millis)
            .min_by_key(DateTime::timestamp_millis)
            .ok_or_else(|| anyhow::anyhow!("alarm time `{local}` has already passed")),
        LocalResult::None => bail!(
            "alarm time `{local}` does not exist in the current timezone (daylight-saving transition)"
        ),
    }
}

#[cfg(test)]
mod tests {
    use chrono::{FixedOffset, TimeZone};

    use super::*;

    fn local_time(
        year: i32,
        month: u32,
        day: u32,
        hour: u32,
        minute: u32,
        second: u32,
    ) -> DateTime<FixedOffset> {
        FixedOffset::east_opt(0)
            .unwrap()
            .with_ymd_and_hms(year, month, day, hour, minute, second)
            .single()
            .unwrap()
    }

    #[test]
    fn clock_time_uses_today_when_still_in_the_future() {
        let now = local_time(2026, 10, 28, 7, 30, 0);
        let target = parse_in_timezone("8:00", now).unwrap();
        assert_eq!(target, local_time(2026, 10, 28, 8, 0, 0));
    }

    #[test]
    fn clock_time_rolls_to_tomorrow_after_it_passes() {
        let now = local_time(2026, 10, 28, 8, 0, 1);
        let target = parse_in_timezone("08:00", now).unwrap();
        assert_eq!(target, local_time(2026, 10, 29, 8, 0, 0));
    }

    #[test]
    fn accepts_seconds_and_an_explicit_future_date() {
        let now = local_time(2026, 10, 28, 7, 30, 0);
        assert_eq!(
            parse_in_timezone("08:00:15", now).unwrap(),
            local_time(2026, 10, 28, 8, 0, 15)
        );
        assert_eq!(
            parse_in_timezone("2026-10-30 18:45", now).unwrap(),
            local_time(2026, 10, 30, 18, 45, 0)
        );
    }

    #[test]
    fn rejects_invalid_and_past_explicit_times() {
        let now = local_time(2026, 10, 28, 7, 30, 0);
        assert!(parse_in_timezone("breakfast", now).is_err());
        assert!(parse_in_timezone("2026-10-27 08:00", now).is_err());
    }
}