use crate::error::CalDirResult;
use crate::event::{Event, EventStatus, Transparency};
use crate::event_time::EventTime;
use icalendar::{Alarm, Calendar, Component, EventLike, Property, Trigger, ValueType};
pub fn generate_ics(event: &Event) -> CalDirResult<String> {
let mut cal = Calendar::new();
let mut ics_event = icalendar::Event::new();
ics_event.uid(&event.uid);
ics_event.summary(&event.summary);
let dtstamp = event
.updated
.unwrap_or_else(chrono::Utc::now)
.format("%Y%m%dT%H%M%SZ")
.to_string();
ics_event.add_property("DTSTAMP", &dtstamp);
if let Some(updated) = event.updated {
let last_modified = updated.format("%Y%m%dT%H%M%SZ").to_string();
ics_event.add_property("LAST-MODIFIED", &last_modified);
}
if let Some(seq) = event.sequence {
ics_event.add_property("SEQUENCE", seq.to_string());
}
add_datetime_property(&mut ics_event, "DTSTART", &event.start);
add_datetime_property(&mut ics_event, "DTEND", &event.end);
if let Some(ref desc) = event.description {
ics_event.description(desc);
}
if let Some(ref loc) = event.location {
ics_event.location(loc);
}
match event.status {
EventStatus::Confirmed => {}
EventStatus::Tentative => {
ics_event.add_property("STATUS", "TENTATIVE");
}
EventStatus::Cancelled => {
ics_event.add_property("STATUS", "CANCELLED");
}
}
if let Some(ref recurrence) = event.recurrence {
ics_event.add_property("RRULE", &recurrence.rrule);
for exdate in &recurrence.exdates {
add_exdate_property(&mut ics_event, exdate);
}
}
if let Some(ref recurrence_id) = event.recurrence_id {
add_datetime_property(&mut ics_event, "RECURRENCE-ID", recurrence_id);
}
if event.transparency == Transparency::Transparent {
ics_event.add_property("TRANSP", "TRANSPARENT");
}
for reminder in &event.reminders {
let trigger = Trigger::before_start(chrono::Duration::minutes(reminder.minutes));
let alarm = Alarm::display("Reminder", trigger);
ics_event.alarm(alarm);
}
if let Some(ref org) = event.organizer {
let mut prop = Property::new("ORGANIZER", format!("mailto:{}", org.email));
if let Some(ref name) = org.name {
prop.add_parameter("CN", name);
}
ics_event.append_property(prop);
}
for attendee in &event.attendees {
let mut prop = Property::new("ATTENDEE", format!("mailto:{}", attendee.email));
if let Some(ref name) = attendee.name {
prop.add_parameter("CN", name);
}
if let Some(partstat) = attendee.response_status {
prop.add_parameter("PARTSTAT", partstat.as_ics_str());
}
ics_event.append_multi_property(prop);
}
if let Some(ref url) = event.conference_url {
ics_event.add_property("URL", url);
}
for prop in &event.custom_properties {
let mut p = Property::new(&prop.name, &prop.value);
for (pkey, pval) in &prop.params {
p.add_parameter(pkey, pval);
}
ics_event.append_property(p);
}
let ics_event = ics_event.done();
cal.push(ics_event);
let cal = cal.done();
let output = strip_ics_bloat(&cal.to_string());
Ok(output)
}
fn strip_ics_bloat(ics: &str) -> String {
let mut result = String::with_capacity(ics.len());
let mut in_valarm = false;
for line in ics.lines() {
if line.starts_with("PRODID:") {
result.push_str("PRODID:CALDIR\r\n");
continue;
}
if line == "CALSCALE:GREGORIAN" {
continue;
}
if line == "BEGIN:VALARM" {
in_valarm = true;
} else if line == "END:VALARM" {
in_valarm = false;
}
if in_valarm && (line.starts_with("DTSTAMP:") || line.starts_with("UID:")) {
continue;
}
result.push_str(line);
result.push_str("\r\n");
}
result
}
fn add_datetime_property(ics_event: &mut icalendar::Event, name: &str, time: &EventTime) {
let mut prop = Property::new(name, time.to_ics_string());
add_time_parameters(&mut prop, time);
ics_event.append_property(prop);
}
fn add_exdate_property(ics_event: &mut icalendar::Event, time: &EventTime) {
let mut prop = Property::new("EXDATE", time.to_ics_string());
add_time_parameters(&mut prop, time);
ics_event.append_multi_property(prop);
}
fn add_time_parameters(prop: &mut Property, time: &EventTime) {
match time {
EventTime::Date(_) => {
prop.append_parameter(ValueType::Date);
}
EventTime::DateTimeZoned { tzid, .. } => {
prop.add_parameter("TZID", tzid);
}
EventTime::DateTimeUtc(_) | EventTime::DateTimeFloating(_) => {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Attendee, EventStatus, ParticipationStatus, Reminders};
use chrono::{NaiveDate, TimeZone, Utc};
fn make_test_event() -> Event {
Event {
uid: "test-event-123@caldir".to_string(),
summary: "Test Event".to_string(),
description: None,
location: None,
start: EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2025, 3, 20, 15, 0, 0).unwrap()),
end: EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2025, 3, 20, 16, 0, 0).unwrap()),
status: EventStatus::Confirmed,
recurrence: None,
recurrence_id: None,
reminders: Reminders(vec![]),
transparency: Transparency::Opaque,
organizer: None,
attendees: vec![],
conference_url: None,
updated: None,
sequence: None,
custom_properties: vec![],
}
}
#[test]
fn test_generate_ics_multiple_attendees() {
let mut event = make_test_event();
event.attendees = vec![
Attendee {
name: Some("Alice".to_string()),
email: "alice@example.com".to_string(),
response_status: Some(ParticipationStatus::Accepted),
},
Attendee {
name: Some("Bob".to_string()),
email: "bob@example.com".to_string(),
response_status: Some(ParticipationStatus::Tentative),
},
Attendee {
name: None,
email: "charlie@example.com".to_string(),
response_status: None,
},
];
let ics = generate_ics(&event).unwrap();
let attendee_count = ics.lines().filter(|l| l.starts_with("ATTENDEE")).count();
assert_eq!(
attendee_count, 3,
"Should have 3 ATTENDEE lines, got {}. ICS:\n{}",
attendee_count, ics
);
assert!(ics.contains("alice@example.com"), "Missing Alice");
assert!(ics.contains("bob@example.com"), "Missing Bob");
assert!(ics.contains("charlie@example.com"), "Missing Charlie");
}
#[test]
fn test_generate_ics_all_day_event_has_value_date() {
let mut event = make_test_event();
event.start = EventTime::Date(NaiveDate::from_ymd_opt(2025, 3, 20).unwrap());
event.end = EventTime::Date(NaiveDate::from_ymd_opt(2025, 3, 21).unwrap());
let ics = generate_ics(&event).unwrap();
assert!(
ics.contains("DTSTART;VALUE=DATE:20250320"),
"DTSTART should have VALUE=DATE parameter. ICS:\n{}",
ics
);
assert!(
ics.contains("DTEND;VALUE=DATE:20250321"),
"DTEND should have VALUE=DATE parameter. ICS:\n{}",
ics
);
}
#[test]
fn test_generate_ics_alarm_is_minimal() {
use crate::event::{Reminder, Reminders};
let mut event = make_test_event();
event.reminders = Reminders(vec![Reminder { minutes: 30 }]);
let ics = generate_ics(&event).unwrap();
println!("Generated ICS:\n{}", ics);
assert!(ics.contains("BEGIN:VALARM"), "Should have VALARM");
assert!(ics.contains("ACTION:DISPLAY"), "Should have ACTION:DISPLAY");
assert!(ics.contains("TRIGGER"), "Should have TRIGGER");
assert!(
ics.contains("DESCRIPTION:Reminder"),
"Should have generic DESCRIPTION:Reminder"
);
let valarm_section: String = ics
.split("BEGIN:VALARM")
.nth(1)
.unwrap()
.split("END:VALARM")
.next()
.unwrap()
.to_string();
assert!(
!valarm_section.contains("UID:"),
"VALARM should not have UID. Got:\n{}",
valarm_section
);
assert!(
!valarm_section.contains("DTSTAMP:"),
"VALARM should not have DTSTAMP. Got:\n{}",
valarm_section
);
}
#[test]
fn test_generate_ics_organizer_has_proper_parameters() {
let mut event = make_test_event();
event.organizer = Some(Attendee {
name: Some("Organizer Name".to_string()),
email: "organizer@example.com".to_string(),
response_status: None,
});
let ics = generate_ics(&event).unwrap();
let organizer_line = ics
.lines()
.find(|l| l.starts_with("ORGANIZER"))
.expect("Should have ORGANIZER line");
assert!(
organizer_line.contains(";CN="),
"CN should be a parameter (;CN=), not part of value. Got: {}",
organizer_line
);
assert!(
organizer_line.contains("mailto:organizer@example.com"),
"Should have mailto value. Got: {}",
organizer_line
);
}
}