caldir-cli 0.9.1

CLI for interacting with your local caldir directory and syncing with external calendar providers
use anyhow::{Context, Result};
use caldir_core::Caldir;
use caldir_core::DateBounds;
use chrono::{DateTime, Duration, TimeZone, Utc};

use crate::render::events_in_range::render_events_in_range;
use crate::utils::parse_date;
use crate::utils::{require_calendars, resolve_calendars};

pub fn run(
    caldir: &Caldir,
    calendar: Option<String>,
    from: Option<String>,
    to: Option<String>,
) -> Result<()> {
    require_calendars(caldir)?;

    let calendars = resolve_calendars(caldir, calendar.as_deref())?;

    let tz: chrono_tz::Tz = iana_time_zone::get_timezone()?.parse()?;

    let (from, to) = resolve_range(
        Utc::now().with_timezone(&tz),
        from.as_deref(),
        to.as_deref(),
    )?;

    render_events_in_range(caldir, calendars, from, to)
}

fn resolve_range<Tz: TimeZone>(
    now: DateTime<Tz>,
    from: Option<&str>,
    to: Option<&str>,
) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
    let tz = now.timezone();

    let today = now.date_naive();

    let from_date = match from {
        Some(s) => parse_date(s).with_context(|| format!("invalid --from date: {s}"))?,
        None => today,
    };
    let to_date = match to {
        Some(s) => parse_date(s).with_context(|| format!("invalid --to date: {s}"))?,
        None => today + Duration::days(2),
    };

    let start = from_date
        .start_of_date()
        .and_local_timezone(tz.clone())
        .earliest()
        .unwrap()
        .with_timezone(&Utc);

    let end = to_date
        .end_of_date()
        .and_local_timezone(tz)
        .latest()
        .unwrap()
        .with_timezone(&Utc);

    Ok((start, end))
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::NaiveDate;
    use chrono_tz::Europe::Stockholm;

    fn stockholm_date(d: DateTime<Utc>) -> NaiveDate {
        d.with_timezone(&Stockholm).date_naive()
    }

    fn stockholm_time(d: DateTime<Utc>) -> chrono::NaiveTime {
        d.with_timezone(&Stockholm).time()
    }

    #[test]
    fn defaults_to_three_day_window_starting_today() {
        let now = Stockholm.with_ymd_and_hms(2026, 5, 13, 12, 0, 0).unwrap();
        let (from, to) = resolve_range(now, None, None).unwrap();

        assert_eq!(
            stockholm_date(from),
            NaiveDate::from_ymd_opt(2026, 5, 13).unwrap(),
        );
        assert_eq!(
            stockholm_date(to),
            NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(),
        );
        assert_eq!(
            stockholm_time(from),
            chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
        );
        assert_eq!(
            stockholm_time(to),
            chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap(),
        );
    }

    #[test]
    fn uses_provided_from_and_to() {
        let now = Stockholm.with_ymd_and_hms(2026, 5, 13, 12, 0, 0).unwrap();
        let (from, to) = resolve_range(now, Some("2026-06-01"), Some("2026-06-10")).unwrap();

        assert_eq!(
            stockholm_date(from),
            NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(),
        );
        assert_eq!(
            stockholm_date(to),
            NaiveDate::from_ymd_opt(2026, 6, 10).unwrap(),
        );
    }

    #[test]
    fn provided_from_with_default_to() {
        let now = Stockholm.with_ymd_and_hms(2026, 5, 13, 12, 0, 0).unwrap();
        let (from, to) = resolve_range(now, Some("2026-04-01"), None).unwrap();

        assert_eq!(
            stockholm_date(from),
            NaiveDate::from_ymd_opt(2026, 4, 1).unwrap(),
        );
        assert_eq!(
            stockholm_date(to),
            NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(),
            "to should still default to today + 2 days",
        );
    }

    #[test]
    fn default_from_with_provided_to() {
        let now = Stockholm.with_ymd_and_hms(2026, 5, 13, 12, 0, 0).unwrap();
        let (from, to) = resolve_range(now, None, Some("2026-07-01")).unwrap();

        assert_eq!(
            stockholm_date(from),
            NaiveDate::from_ymd_opt(2026, 5, 13).unwrap(),
            "from should still default to today",
        );
        assert_eq!(
            stockholm_date(to),
            NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(),
        );
    }

    #[test]
    fn invalid_from_returns_error() {
        let now = Stockholm.with_ymd_and_hms(2026, 5, 13, 12, 0, 0).unwrap();
        let err = resolve_range(now, Some("not-a-date"), None).unwrap_err();
        assert!(err.to_string().contains("--from"));
    }

    #[test]
    fn invalid_to_returns_error() {
        let now = Stockholm.with_ymd_and_hms(2026, 5, 13, 12, 0, 0).unwrap();
        let err = resolve_range(now, None, Some("2026/07/01")).unwrap_err();
        assert!(err.to_string().contains("--to"));
    }
}