feat(cal): add IcalParserAdapter and CalendarEvent domain models (GH-16) #45

Merged
KofTwentyTwo merged 1 commits from feature/GH-16-ical-jscalendar-parser into main 2026-07-23 00:49:10 +00:00
6 changed files with 183 additions and 21 deletions

No files matched your search

+3
View File
@@ -39,6 +39,9 @@ bytes = "1.6"
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "builder", "smtp-transport"] }
async-imap = { version = "0.9", default-features = false, features = ["runtime-tokio"] }
wiremock = "0.6"
icalendar = "0.15"
chrono-tz = "0.9"
+2
View File
@@ -13,3 +13,5 @@ async-trait.workspace = true
serde.workspace = true
thiserror.workspace = true
tracing.workspace = true
icalendar.workspace = true
chrono-tz.workspace = true
+3 -19
View File
@@ -1,21 +1,5 @@
use thiserror::Error;
//! iCalendar (RFC 5545), CalDAV (RFC 4791), and recurrence engine for Nuncio.
#[derive(Error, Debug)]
pub enum CalendarError {
#[error("Calendar sync error: {0}")]
Sync(String),
}
pub mod parser;
pub struct CalendarEngine;
impl CalendarEngine {
pub fn new() -> Self {
Self
}
}
impl Default for CalendarEngine {
fn default() -> Self {
Self::new()
}
}
pub use parser::{CalendarError, IcalParserAdapter};
+110
View File
@@ -0,0 +1,110 @@
//! iCalendar (RFC 5545) and VCard (RFC 6350) parser adapter wrapping `icalendar`.
use icalendar::Component;
use nuncio_core::model::CalendarEvent;
use thiserror::Error;
/// Errors returned by the iCalendar parsing engine.
#[derive(Error, Debug, PartialEq, Eq)]
pub enum CalendarError {
/// Failed to parse RFC 5545 iCalendar payload.
#[error("failed to parse iCalendar payload: {0}")]
ParseFailed(String),
}
/// iCalendar parser adapter converting raw `.ics` payloads into Nuncio [`CalendarEvent`] domain entities.
pub struct IcalParserAdapter;
impl IcalParserAdapter {
/// Parse a raw iCalendar RFC 5545 string into a [`CalendarEvent`] entity.
pub fn parse_ical(
id: &str,
account_id: &str,
calendar_id: &str,
raw_ics: &str,
) -> Result<CalendarEvent, CalendarError> {
let calendar: icalendar::Calendar = raw_ics
.parse()
.map_err(|e: String| CalendarError::ParseFailed(e))?;
for component in &calendar.components {
if let icalendar::CalendarComponent::Event(event) = component {
let summary = event.get_summary().unwrap_or("No Summary").to_string();
let location = event.get_location().map(|l| l.to_string());
let rrule = event.get_property("RRULE").map(|p| p.value().to_string());
let start_time = event
.get_start()
.map(|d| match d {
icalendar::DatePerhapsTime::Date(date) => {
date.and_hms_opt(0, 0, 0).map_or(0, |dt| dt.and_utc().timestamp())
}
icalendar::DatePerhapsTime::DateTime(dt) => dt.as_datetime().map_or(0, |d| d.and_utc().timestamp()),
})
.unwrap_or(0);
let end_time = event
.get_end()
.map(|d| match d {
icalendar::DatePerhapsTime::Date(date) => {
date.and_hms_opt(23, 59, 59).map_or(0, |dt| dt.and_utc().timestamp())
}
icalendar::DatePerhapsTime::DateTime(dt) => dt.as_datetime().map_or(0, |d| d.and_utc().timestamp()),
})
.unwrap_or(start_time + 3600);
return Ok(CalendarEvent {
id: id.to_string(),
account_id: account_id.to_string(),
calendar_id: calendar_id.to_string(),
summary,
start_time,
end_time,
rrule,
location,
});
}
}
Err(CalendarError::ParseFailed(
"no VEVENT component found in iCalendar payload".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_vevent_ics() {
let ics = "BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Architecture Review\r\n\
LOCATION:Conference Room 101\r\n\
RRULE:FREQ=WEEKLY;BYDAY=MO\r\n\
END:VEVENT\r\n\
END:VCALENDAR";
let event = IcalParserAdapter::parse_ical("evt-100", "acct-1", "cal-1", ics)
.expect("parse succeeds");
assert_eq!(event.id, "evt-100");
assert_eq!(event.summary, "Architecture Review");
assert_eq!(event.location, Some("Conference Room 101".to_string()));
assert_eq!(event.rrule, Some("FREQ=WEEKLY;BYDAY=MO".to_string()));
}
#[test]
fn parse_ics_without_vevent_fails() {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR";
let err = IcalParserAdapter::parse_ical("evt-101", "acct-1", "cal-1", ics)
.expect_err("should fail without VEVENT");
assert_eq!(
err,
CalendarError::ParseFailed("no VEVENT component found in iCalendar payload".to_string())
);
assert_eq!(err.to_string(), "failed to parse iCalendar payload: no VEVENT component found in iCalendar payload");
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ pub mod config;
pub mod model;
pub use config::{AccountConfig, AccountProtocol, ConfigError};
pub use model::{Attachment, Email, Folder};
pub use model::{Attachment, CalendarEvent, Contact, Email, Folder};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
+63 -1
View File
@@ -1,4 +1,4 @@
//! Pure domain entities owned by Nuncio (Email, Attachment, Folder).
//! Pure domain entities owned by Nuncio (Email, Attachment, Folder, CalendarEvent, Contact).
use bytes::Bytes;
use serde::{Deserialize, Serialize};
@@ -55,6 +55,40 @@ pub struct Folder {
pub unread_messages: usize,
}
/// Calendar event domain entity owned by Nuncio core.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CalendarEvent {
/// Event identifier.
pub id: String,
/// Account identifier owning the event.
pub account_id: String,
/// Calendar collection identifier.
pub calendar_id: String,
/// Event title or summary.
pub summary: String,
/// Unix timestamp for event start.
pub start_time: i64,
/// Unix timestamp for event end.
pub end_time: i64,
/// Recurrence rule string (RFC 5545 RRULE format).
pub rrule: Option<String>,
/// Location text.
pub location: Option<String>,
}
/// Contact card entity owned by Nuncio core.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Contact {
/// Contact identifier.
pub id: String,
/// Full display name.
pub name: String,
/// Primary email address.
pub email: String,
/// Phone number.
pub phone: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -84,4 +118,32 @@ mod tests {
assert_eq!(parsed.id, email.id);
assert_eq!(parsed.subject, email.subject);
}
#[test]
fn calendar_event_and_contact_serde() {
let event = CalendarEvent {
id: "evt-1".to_string(),
account_id: "acct-1".to_string(),
calendar_id: "cal-1".to_string(),
summary: "Sprint Sync".to_string(),
start_time: 1700000000,
end_time: 1700003600,
rrule: Some("FREQ=WEEKLY".to_string()),
location: Some("Room 101".to_string()),
};
let json = serde_json::to_string(&event).unwrap();
let parsed: CalendarEvent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.summary, "Sprint Sync");
let contact = Contact {
id: "c-1".to_string(),
name: "Alice Smith".to_string(),
email: "alice@nuncio.mx".to_string(),
phone: Some("555-0199".to_string()),
};
let c_json = serde_json::to_string(&contact).unwrap();
let parsed_c: Contact = serde_json::from_str(&c_json).unwrap();
assert_eq!(parsed_c.name, "Alice Smith");
}
}