caldir-cli 0.12.0

CLI for interacting with your local caldir directory and syncing with external calendar providers
use anyhow::Result as AnyhowResult;
use caldir_core::{Caldir, Calendar, Event, EventTime, ParticipationStatus, Status, TimeFormat};
use chrono::{DateTime, NaiveDate, SecondsFormat, Utc};
use serde::ser::{Serialize, SerializeSeq, SerializeStruct, Serializer};

use crate::output::event::is_visible;
use crate::output::time::local_date;

pub struct AgendaView {
    pub entries: Vec<AgendaEntry>,
    range_start: NaiveDate,
    range_end: NaiveDate,
    time_format: TimeFormat,
}

pub struct AgendaEntry {
    pub event: Event,
    pub calendar: Option<String>,
    pub rsvp: Option<ParticipationStatus>,
}

impl AgendaView {
    pub fn collect(
        caldir: &Caldir,
        calendars: Vec<Calendar>,
        from: DateTime<Utc>,
        to: DateTime<Utc>,
    ) -> AnyhowResult<Self> {
        let range_start = from.with_timezone(&chrono::Local).date_naive();
        let range_end = to.with_timezone(&chrono::Local).date_naive();
        let mut entries = Vec::new();

        for calendar in calendars {
            let calendar_slug = calendar.slug().map(str::to_owned);
            let remote_email = calendar.remote_email().map(str::to_owned);

            for event in calendar.expanded_events_in_range(from, to)? {
                if !is_visible(&event) {
                    continue;
                }

                let rsvp = resolve_rsvp(&event, remote_email.as_deref());

                entries.push(AgendaEntry {
                    calendar: calendar_slug.clone(),
                    rsvp,
                    event,
                });
            }
        }

        entries.sort_by(|a, b| {
            local_date(&a.event.start)
                .cmp(&local_date(&b.event.start))
                .then_with(|| {
                    a.event
                        .start
                        .is_date()
                        .cmp(&b.event.start.is_date())
                        .reverse()
                })
                .then_with(|| a.event.start.to_utc().cmp(&b.event.start.to_utc()))
        });

        Ok(Self {
            time_format: caldir.config().time_format(),
            entries,
            range_start,
            range_end,
        })
    }

    pub(crate) fn time_format(&self) -> TimeFormat {
        self.time_format
    }

    pub(crate) fn range_start(&self) -> NaiveDate {
        self.range_start
    }

    pub(crate) fn range_end(&self) -> NaiveDate {
        self.range_end
    }
}

impl Serialize for AgendaView {
    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut seq = serializer.serialize_seq(Some(self.entries.len()))?;
        for entry in &self.entries {
            seq.serialize_element(entry)?;
        }
        seq.end()
    }
}

impl Serialize for AgendaEntry {
    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut state = serializer.serialize_struct("AgendaEntry", 13)?;
        state.serialize_field("instance_id", &self.event.event_instance_id().to_string())?;
        state.serialize_field("uid", self.event.uid.as_str())?;
        state.serialize_field("calendar", &self.calendar)?;
        state.serialize_field("title", &self.event.summary)?;
        state.serialize_field("all_day", &self.event.start.is_date())?;
        state.serialize_field("start", &serialize_time(&self.event.start))?;
        state.serialize_field("end", &self.event.end.as_ref().map(serialize_time))?;
        state.serialize_field("tzid", &event_tzid(&self.event.start))?;
        state.serialize_field("location", &self.event.location)?;
        state.serialize_field("description", &self.event.description)?;
        state.serialize_field("status", status_wire_value(self.event.status))?;
        state.serialize_field("rsvp", &self.rsvp.map(rsvp_wire_value))?;
        state.serialize_field("recurring", &self.event.recurrence_id.is_some())?;
        state.end()
    }
}

fn resolve_rsvp(event: &Event, remote_email: Option<&str>) -> Option<ParticipationStatus> {
    remote_email
        .filter(|email| event.is_invite_for(email))
        .and_then(|email| event.attendee_status(email))
}

fn serialize_time(time: &EventTime) -> String {
    match time {
        EventTime::Date(date) => date.format("%Y-%m-%d").to_string(),
        EventTime::DateTimeUtc(datetime) => datetime.to_rfc3339_opts(SecondsFormat::AutoSi, true),
        EventTime::DateTimeFloating(_) => time
            .to_local_tz(&chrono::Local)
            .to_rfc3339_opts(SecondsFormat::AutoSi, false),
        EventTime::DateTimeZoned { tzid, .. } => tzid
            .parse::<chrono_tz::Tz>()
            .map(|tz| {
                time.to_local_tz(&tz)
                    .to_rfc3339_opts(SecondsFormat::AutoSi, false)
            })
            .unwrap_or_else(|_| {
                time.to_local_tz(&chrono::Local)
                    .to_rfc3339_opts(SecondsFormat::AutoSi, false)
            }),
    }
}

fn event_tzid(time: &EventTime) -> Option<&str> {
    match time {
        EventTime::DateTimeZoned { tzid, .. } => Some(tzid),
        _ => None,
    }
}

fn status_wire_value(status: Status) -> &'static str {
    match status {
        Status::Confirmed => "confirmed",
        Status::Tentative => "tentative",
        Status::Cancelled => "cancelled",
    }
}

fn rsvp_wire_value(status: ParticipationStatus) -> &'static str {
    match status {
        ParticipationStatus::Accepted => "accepted",
        ParticipationStatus::Declined => "declined",
        ParticipationStatus::Tentative => "tentative",
        ParticipationStatus::NeedsAction => "needs_action",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::output::{Output, TextRender};
    use caldir_core::{Attendee, EventUid, Organizer, RecurrenceId};
    use chrono::{NaiveDateTime, TimeZone};
    use pretty_assertions::assert_eq;
    use serde_json::json;

    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(year, month, day).unwrap()
    }

    fn datetime(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> NaiveDateTime {
        date(year, month, day).and_hms_opt(hour, minute, 0).unwrap()
    }

    fn entry(event: Event) -> AgendaEntry {
        AgendaEntry {
            calendar: Some("personal".to_string()),
            rsvp: None,
            event,
        }
    }

    fn view(entries: Vec<AgendaEntry>) -> AgendaView {
        AgendaView {
            time_format: TimeFormat::H24,
            entries,
            range_start: date(2026, 8, 14),
            range_end: date(2026, 8, 20),
        }
    }

    #[test]
    fn serializes_zoned_recurring_invite() {
        let start = EventTime::DateTimeZoned {
            datetime: datetime(2026, 8, 14, 16, 0),
            tzid: "Europe/Stockholm".to_string(),
        };
        let mut event = Event::new("Friday retro", start.clone());
        event.uid = EventUid::new("retro@caldir");
        event.end = Some(EventTime::DateTimeZoned {
            datetime: datetime(2026, 8, 14, 16, 30),
            tzid: "Europe/Stockholm".to_string(),
        });
        event.location = Some("Conference room".to_string());
        event.description = Some("Weekly retrospective".to_string());
        event.status = Status::Tentative;
        event.recurrence_id = Some(RecurrenceId::from_event_time(start));

        let mut entry = entry(event);
        entry.rsvp = Some(ParticipationStatus::Accepted);

        assert_eq!(
            serde_json::to_value(entry).unwrap(),
            json!({
                "instance_id": "retro@caldir__TZID=Europe/Stockholm:20260814T160000",
                "uid": "retro@caldir",
                "calendar": "personal",
                "title": "Friday retro",
                "all_day": false,
                "start": "2026-08-14T16:00:00+02:00",
                "end": "2026-08-14T16:30:00+02:00",
                "tzid": "Europe/Stockholm",
                "location": "Conference room",
                "description": "Weekly retrospective",
                "status": "tentative",
                "rsvp": "accepted",
                "recurring": true,
            })
        );
    }

    #[test]
    fn serializes_utc_event() {
        let start = Utc.with_ymd_and_hms(2026, 8, 14, 14, 0, 0).unwrap();
        let mut event = Event::new("UTC call", EventTime::DateTimeUtc(start));
        event.uid = EventUid::new("utc@caldir");
        event.end = Some(EventTime::DateTimeUtc(
            start + chrono::Duration::minutes(30),
        ));

        let json = serde_json::to_value(entry(event)).unwrap();

        assert_eq!(json["start"], "2026-08-14T14:00:00Z");
        assert_eq!(json["end"], "2026-08-14T14:30:00Z");
        assert_eq!(json["tzid"], serde_json::Value::Null);
        assert_eq!(json["status"], "confirmed");
        assert_eq!(json["rsvp"], serde_json::Value::Null);
        assert_eq!(json["recurring"], false);
    }

    #[test]
    fn serializes_floating_event_in_system_timezone() {
        let start = datetime(2026, 8, 14, 16, 0);
        let event = Event::new("Floating", EventTime::DateTimeFloating(start));

        let json = serde_json::to_value(entry(event)).unwrap();
        let expected = EventTime::DateTimeFloating(start)
            .to_local_tz(&chrono::Local)
            .to_rfc3339_opts(SecondsFormat::AutoSi, false);

        assert_eq!(json["start"], expected);
        assert_eq!(json["tzid"], serde_json::Value::Null);
    }

    #[test]
    fn serializes_all_day_event_with_exclusive_end() {
        let mut event = Event::new("Trip", EventTime::Date(date(2026, 8, 14)));
        event.end = Some(EventTime::Date(date(2026, 8, 17)));

        let json = serde_json::to_value(entry(event)).unwrap();

        assert_eq!(json["all_day"], true);
        assert_eq!(json["start"], "2026-08-14");
        assert_eq!(json["end"], "2026-08-17");
        assert_eq!(json["tzid"], serde_json::Value::Null);
    }

    #[test]
    fn resolves_rsvp_only_for_invites_to_calendar_account() {
        let mut event = Event::new("Invite", EventTime::Date(date(2026, 8, 14)));
        event.organizer = Some(Organizer::new("host@example.com"));
        let mut attendee = Attendee::new("me@example.com");
        attendee.status = Some(ParticipationStatus::Accepted);
        event.attendees.push(attendee);

        assert_eq!(
            resolve_rsvp(&event, Some("me@example.com")),
            Some(ParticipationStatus::Accepted)
        );
        assert_eq!(resolve_rsvp(&event, Some("other@example.com")), None);
        assert_eq!(resolve_rsvp(&event, None), None);
    }

    #[test]
    fn serializes_empty_view_as_bare_array() {
        assert_eq!(view(Vec::new()).to_json(), json!([]));
    }

    #[test]
    fn text_repeats_multi_day_event_but_json_does_not() {
        let mut event = Event::new("Trip", EventTime::Date(date(2026, 8, 14)));
        event.end = Some(EventTime::Date(date(2026, 8, 17)));
        let view = view(vec![entry(event)]);

        assert_eq!(view.entries.len(), 1);
        assert_eq!(view.to_json().as_array().unwrap().len(), 1);
        assert_eq!(view.to_text().matches("Trip").count(), 3);
    }
}