clck 0.1.0

A responsive cross-platform countdown alarm for the terminal.
Documentation
use chrono::{DateTime, Datelike, FixedOffset, LocalResult, NaiveDate, NaiveTime, TimeZone, Utc};
use regex::Regex;
use std::{collections::HashSet, fmt::Display, time::Duration};
use thiserror::Error;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Candidate {
    pub source: String,
    pub target: DateTime<FixedOffset>,
    pub timezone: String,
}

impl Candidate {
    pub fn new(
        source: impl Into<String>,
        target: DateTime<FixedOffset>,
        timezone: impl Into<String>,
    ) -> Self {
        Self {
            source: source.into(),
            target,
            timezone: timezone.into(),
        }
    }

    pub fn display_target(&self) -> String {
        format!(
            "{} ({})",
            self.target.format("%Y-%m-%d %H:%M:%S %:z"),
            self.timezone
        )
    }
}

#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum ScheduleError {
    #[error("could not find an explicit date and time; try examples such as `2:50pm`, `tomorrow at 9am`, or `June 12 at 09:00`")]
    InvalidExpression,
    #[error("resolved target is not in the future: {0}")]
    PastTarget(DateTime<FixedOffset>),
    #[error("local time does not exist because of a daylight-saving transition")]
    NonexistentLocalTime,
    #[error("local time is ambiguous because of a daylight-saving transition")]
    AmbiguousLocalTime,
    #[error("target is too far in the future to represent as a countdown")]
    DurationOutOfRange,
    #[error("could not determine the system's local IANA time zone")]
    LocalTimeZoneUnavailable,
}

pub fn duration_until(
    now: DateTime<FixedOffset>,
    target: DateTime<FixedOffset>,
) -> Result<Duration, ScheduleError> {
    let delta = target.signed_duration_since(now);
    if delta <= chrono::Duration::zero() {
        return Err(ScheduleError::PastTarget(target));
    }
    delta
        .to_std()
        .map_err(|_| ScheduleError::DurationOutOfRange)
}

pub fn parse_direct(input: &str) -> Result<Candidate, ScheduleError> {
    let timezone = local_timezone()?;
    let now = Utc::now().with_timezone(&timezone);
    parse_direct_in(input, now, timezone)
}

pub fn parse_direct_in<Tz>(
    input: &str,
    now: DateTime<Tz>,
    timezone: Tz,
) -> Result<Candidate, ScheduleError>
where
    Tz: TimeZone + Copy + Display,
    Tz::Offset: Display,
{
    let normalized = normalize_expression(input);
    let (date, time) = parse_date_and_time(&normalized, now.date_naive())
        .ok_or(ScheduleError::InvalidExpression)?;
    let resolved = match timezone.from_local_datetime(&date.and_time(time)) {
        LocalResult::Single(value) => value,
        LocalResult::None => return Err(ScheduleError::NonexistentLocalTime),
        LocalResult::Ambiguous(_, _) => return Err(ScheduleError::AmbiguousLocalTime),
    };
    let target = resolved.fixed_offset();
    duration_until(now.fixed_offset(), target)?;
    Ok(Candidate::new(input.trim(), target, timezone.to_string()))
}

pub fn extract_candidates(text: &str) -> Result<Vec<Candidate>, ScheduleError> {
    let timezone = local_timezone()?;
    let now = Utc::now().with_timezone(&timezone);
    Ok(extract_candidates_in(text, now, timezone))
}

pub fn extract_candidates_in<Tz>(text: &str, now: DateTime<Tz>, timezone: Tz) -> Vec<Candidate>
where
    Tz: TimeZone + Copy + Display,
    Tz::Offset: Display,
{
    let mut seen = HashSet::new();
    let mut occupied = Vec::new();
    let mut candidates = Vec::new();
    for matched in candidate_patterns()
        .iter()
        .flat_map(|pattern| pattern.find_iter(text))
    {
        let mut end = matched.end();
        if text.as_bytes().get(end) == Some(&b'.')
            && (matched.as_str().ends_with("a.m") || matched.as_str().ends_with("p.m"))
        {
            end += 1;
        }
        if occupied
            .iter()
            .any(|(start, occupied_end)| matched.start() < *occupied_end && end > *start)
        {
            continue;
        }
        if let Ok(candidate) = parse_direct_in(&text[matched.start()..end], now.clone(), timezone) {
            occupied.push((matched.start(), end));
            if seen.insert(candidate.target) {
                candidates.push(candidate);
            }
        }
    }
    candidates.sort_by_key(|candidate| candidate.target);
    candidates
}

fn local_timezone() -> Result<chrono_tz::Tz, ScheduleError> {
    let name =
        iana_time_zone::get_timezone().map_err(|_| ScheduleError::LocalTimeZoneUnavailable)?;
    name.parse()
        .map_err(|_| ScheduleError::LocalTimeZoneUnavailable)
}

fn normalize_expression(input: &str) -> String {
    input
        .trim()
        .to_ascii_lowercase()
        .replace("a.m.", "am")
        .replace("p.m.", "pm")
        .replace("a.m", "am")
        .replace("p.m", "pm")
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}

fn parse_date_and_time(input: &str, today: NaiveDate) -> Option<(NaiveDate, NaiveTime)> {
    let (date, time_input) = if let Some(rest) = input.strip_prefix("today") {
        (today, strip_at(rest).to_owned())
    } else if let Some(rest) = input.strip_prefix("tomorrow") {
        (today.succ_opt()?, strip_at(rest).to_owned())
    } else {
        let mut parts = input.split_whitespace();
        let first = parts.next()?;
        if let Some(month) = parse_month(first) {
            let day = parts.next()?.parse().ok()?;
            let date = NaiveDate::from_ymd_opt(today.year(), month, day)?;
            let rest = parts.collect::<Vec<_>>().join(" ");
            (date, strip_at(&rest).to_owned())
        } else {
            (today, input.to_owned())
        }
    };
    Some((date, parse_time(&time_input)?))
}

fn strip_at(input: &str) -> &str {
    input.trim().strip_prefix("at ").unwrap_or(input.trim())
}

fn parse_month(input: &str) -> Option<u32> {
    [
        "january",
        "february",
        "march",
        "april",
        "may",
        "june",
        "july",
        "august",
        "september",
        "october",
        "november",
        "december",
    ]
    .iter()
    .position(|month| *month == input)
    .map(|index| index as u32 + 1)
}

fn parse_time(input: &str) -> Option<NaiveTime> {
    let input = input.trim();
    let (clock, suffix) = if let Some(clock) = input.strip_suffix("am") {
        (clock.trim(), Some("am"))
    } else if let Some(clock) = input.strip_suffix("pm") {
        (clock.trim(), Some("pm"))
    } else {
        (input, None)
    };
    if suffix.is_none() && !clock.contains(':') {
        return None;
    }
    let mut parts = clock.split(':');
    let mut hour: u32 = parts.next()?.trim().parse().ok()?;
    let minute: u32 = parts.next().unwrap_or("0").trim().parse().ok()?;
    if parts.next().is_some() || minute >= 60 {
        return None;
    }
    match suffix {
        Some("am") if (1..=12).contains(&hour) => hour %= 12,
        Some("pm") if (1..=12).contains(&hour) => hour = hour % 12 + 12,
        Some(_) => return None,
        None if hour < 24 => {}
        None => return None,
    }
    NaiveTime::from_hms_opt(hour, minute, 0)
}

fn candidate_patterns() -> Vec<Regex> {
    let time =
        r"(?:(?:1[0-2]|0?[1-9])(?::[0-5]\d)?\s*(?:a\.?m\.?|p\.?m\.?)|(?:[01]?\d|2[0-3]):[0-5]\d)";
    let month = r"(?:january|february|march|april|may|june|july|august|september|october|november|december)";
    [
        format!(r"(?i)\b(?:today|tomorrow)\s+(?:at\s+)?{time}\b"),
        format!(r"(?i)\b{month}\s+\d{{1,2}}\s+(?:at\s+)?{time}\b"),
        r"(?i)\b(?:1[0-2]|0?[1-9])(?::[0-5]\d)?\s*(?:a\.?m\.?|p\.?m\.?)\b".to_owned(),
        r"(?i)\b(?:[01]?\d|2[0-3]):[0-5]\d\b".to_owned(),
    ]
    .into_iter()
    .map(|pattern| Regex::new(&pattern).expect("candidate regex is valid"))
    .collect()
}

#[cfg(test)]
mod tests {
    use super::{duration_until, extract_candidates_in, parse_direct_in, Candidate, ScheduleError};
    use chrono::{Datelike, FixedOffset, TimeZone, Timelike};
    use chrono_tz::America::New_York;
    use std::time::Duration;

    fn fixed(hour: u32, minute: u32) -> chrono::DateTime<FixedOffset> {
        FixedOffset::east_opt(7_200)
            .unwrap()
            .with_ymd_and_hms(2026, 6, 10, hour, minute, 0)
            .unwrap()
    }

    fn context_now() -> chrono::DateTime<chrono_tz::Tz> {
        New_York.with_ymd_and_hms(2026, 6, 10, 8, 0, 0).unwrap()
    }

    #[test]
    fn converts_future_target_to_countdown_duration() {
        assert_eq!(
            duration_until(fixed(12, 0), fixed(12, 1)).unwrap(),
            Duration::from_secs(60)
        );
    }

    #[test]
    fn rejects_past_and_equal_targets() {
        assert_eq!(
            duration_until(fixed(12, 0), fixed(11, 59)).unwrap_err(),
            ScheduleError::PastTarget(fixed(11, 59))
        );
        assert!(duration_until(fixed(12, 0), fixed(12, 0)).is_err());
    }

    #[test]
    fn candidate_formats_resolved_target_with_zone() {
        let candidate = Candidate::new("tomorrow at 9am", fixed(9, 0), "Europe/Warsaw");
        assert_eq!(
            candidate.display_target(),
            "2026-06-10 09:00:00 +02:00 (Europe/Warsaw)"
        );
    }

    #[test]
    fn parses_supported_direct_expressions() {
        for (input, expected_day, expected_hour, expected_minute) in [
            ("2:50pm", 10, 14, 50),
            ("3 p.m.", 10, 15, 0),
            ("14:30", 10, 14, 30),
            ("today at 9 AM", 10, 9, 0),
            ("tomorrow at 9am", 11, 9, 0),
            ("June 12 at 09:00", 12, 9, 0),
        ] {
            let candidate = parse_direct_in(input, context_now(), New_York).unwrap();
            assert_eq!(candidate.target.day(), expected_day);
            assert_eq!(candidate.target.hour(), expected_hour);
            assert_eq!(candidate.target.minute(), expected_minute);
            assert_eq!(candidate.target.second(), 0);
        }
    }

    #[test]
    fn rejects_past_explicit_and_time_only_values() {
        assert!(matches!(
            parse_direct_in("7am", context_now(), New_York),
            Err(ScheduleError::PastTarget(_))
        ));
        assert!(matches!(
            parse_direct_in("June 9 at 09:00", context_now(), New_York),
            Err(ScheduleError::PastTarget(_))
        ));
    }

    #[test]
    fn rejects_nonexistent_and_ambiguous_dst_values() {
        let spring = New_York.with_ymd_and_hms(2026, 3, 7, 12, 0, 0).unwrap();
        assert_eq!(
            parse_direct_in("March 8 at 02:30", spring, New_York).unwrap_err(),
            ScheduleError::NonexistentLocalTime
        );
        let fall = New_York.with_ymd_and_hms(2026, 10, 31, 12, 0, 0).unwrap();
        assert_eq!(
            parse_direct_in("November 1 at 01:30", fall, New_York).unwrap_err(),
            ScheduleError::AmbiguousLocalTime
        );
    }

    #[test]
    fn extracts_one_and_multiple_explicit_candidates() {
        let one = extract_candidates_in("Please call me tomorrow at 9am.", context_now(), New_York);
        assert_eq!(one.len(), 1);
        assert_eq!(one[0].source, "tomorrow at 9am");

        let dotted = extract_candidates_in("Please call me at 3 p.m.", context_now(), New_York);
        assert_eq!(dotted.len(), 1);
        assert_eq!(dotted[0].source, "3 p.m.");

        let many = extract_candidates_in(
            "Try tomorrow at 9am or June 12 at 14:30.",
            context_now(),
            New_York,
        );
        assert_eq!(many.len(), 2);
    }

    #[test]
    fn ignores_vague_phrases_and_deduplicates_resolved_targets() {
        assert!(extract_candidates_in(
            "Let's talk later in the afternoon after lunch.",
            context_now(),
            New_York,
        )
        .is_empty());
        let candidates = extract_candidates_in(
            "Use tomorrow at 9am, or June 11 at 09:00.",
            context_now(),
            New_York,
        );
        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].source, "tomorrow at 9am");
    }
}