cardinal-app-core 0.1.4

Core command grammar and domain model for Cardinal.
Documentation
//! Calendar domain types.
//!
//! Cardinal starts with local vdir/`.ics` data, but these types should not care
//! whether events came from CalDAV, Google, JMAP, or fixtures.

use std::marker::PhantomData;

use thiserror::Error;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalendarAccount {
    pub name: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Calendar {
    pub account: String,
    pub name: String,
    pub path: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventId(pub String);

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalendarEvent {
    pub id: EventId,
    pub calendar: String,
    pub title: String,
    pub starts_at: String,
    pub ends_at: String,
    pub location: Option<String>,
    pub description: Option<String>,
    pub status: EventStatus,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventStatus {
    Confirmed,
    Tentative,
    Cancelled,
    Unknown,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InviteSummary {
    pub event_id: EventId,
    pub message_id: String,
    pub title: String,
    pub organizer: String,
    pub starts_at: String,
    pub ends_at: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Dirty;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Validated;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Atomic;

#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum EventValidationError {
    #[error("event id is required")]
    MissingId,
    #[error("event calendar is required")]
    MissingCalendar,
    #[error("event title is required")]
    MissingTitle,
    #[error("event start timestamp is required")]
    MissingStartsAt,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventEdit<State> {
    pub id: EventId,
    pub calendar: String,
    pub title: String,
    pub starts_at: String,
    pub ends_at: String,
    pub location: Option<String>,
    pub description: Option<String>,
    pub status: EventStatus,
    _state: PhantomData<State>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalendarWrite<State> {
    event: EventEdit<Validated>,
    _state: PhantomData<State>,
}

impl CalendarEvent {
    pub fn is_cancelled(&self) -> bool {
        self.status == EventStatus::Cancelled
    }
}

impl EventEdit<Dirty> {
    pub fn new(id: EventId, calendar: impl Into<String>) -> Self {
        Self {
            id,
            calendar: calendar.into(),
            title: String::new(),
            starts_at: String::new(),
            ends_at: String::new(),
            location: None,
            description: None,
            status: EventStatus::Confirmed,
            _state: PhantomData,
        }
    }

    pub fn with_title(mut self, title: impl Into<String>) -> Self {
        self.title = title.into();
        self
    }

    pub fn with_starts_at(mut self, starts_at: impl Into<String>) -> Self {
        self.starts_at = starts_at.into();
        self
    }

    pub fn with_ends_at(mut self, ends_at: impl Into<String>) -> Self {
        self.ends_at = ends_at.into();
        self
    }

    pub fn with_location(mut self, location: Option<String>) -> Self {
        self.location = location;
        self
    }

    pub fn with_description(mut self, description: Option<String>) -> Self {
        self.description = description;
        self
    }

    pub fn with_status(mut self, status: EventStatus) -> Self {
        self.status = status;
        self
    }

    pub fn validate(self) -> Result<EventEdit<Validated>, EventValidationError> {
        if self.id.0.trim().is_empty() {
            return Err(EventValidationError::MissingId);
        }
        if self.calendar.trim().is_empty() {
            return Err(EventValidationError::MissingCalendar);
        }
        if self.title.trim().is_empty() {
            return Err(EventValidationError::MissingTitle);
        }
        if self.starts_at.trim().is_empty() {
            return Err(EventValidationError::MissingStartsAt);
        }

        Ok(EventEdit {
            id: EventId(self.id.0.trim().to_owned()),
            calendar: self.calendar.trim().to_owned(),
            title: self.title.trim().to_owned(),
            starts_at: self.starts_at.trim().to_owned(),
            ends_at: self.ends_at.trim().to_owned(),
            location: self
                .location
                .and_then(|value| (!value.trim().is_empty()).then_some(value.trim().to_owned())),
            description: self
                .description
                .and_then(|value| (!value.trim().is_empty()).then_some(value.trim().to_owned())),
            status: self.status,
            _state: PhantomData,
        })
    }
}

impl EventEdit<Validated> {
    pub fn prepare_atomic_write(self) -> CalendarWrite<Atomic> {
        CalendarWrite {
            event: self,
            _state: PhantomData,
        }
    }
}

impl CalendarWrite<Atomic> {
    pub fn event(&self) -> &EventEdit<Validated> {
        &self.event
    }

    pub fn into_event(self) -> EventEdit<Validated> {
        self.event
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn recognizes_cancelled_event() {
        let event = CalendarEvent {
            id: EventId("evt-1".into()),
            calendar: "work".into(),
            title: "Interview".into(),
            starts_at: "2026-05-11T10:00:00-06:00".into(),
            ends_at: "2026-05-11T11:00:00-06:00".into(),
            location: None,
            description: None,
            status: EventStatus::Cancelled,
        };

        assert!(event.is_cancelled());
    }

    #[test]
    fn event_edit_typestate_requires_validation_before_atomic_write() {
        let invalid = EventEdit::<Dirty>::new(EventId(String::new()), "work")
            .with_title("Interview")
            .with_starts_at("20260511T160000Z");
        assert_eq!(invalid.validate(), Err(EventValidationError::MissingId));

        let atomic = EventEdit::<Dirty>::new(EventId("evt-1".into()), "work")
            .with_title("Interview")
            .with_starts_at("20260511T160000Z")
            .with_ends_at("20260511T170000Z")
            .validate()
            .expect("event should validate")
            .prepare_atomic_write();
        assert_eq!(atomic.event().title, "Interview");
        assert_eq!(atomic.event().calendar, "work");
    }
}