use crate::common::content_line::{parse_content_line, split_lines, unfold};
use crate::common::property::Property;
use crate::error::{Error, Result};
use crate::ical::alarm::{Alarm, AlarmAction};
use crate::ical::calendar::Calendar;
use crate::ical::event::Event;
use crate::ical::todo::Todo;
pub(crate) fn parse_calendar(input: &str) -> Result<Calendar> {
let unfolded = unfold(input);
let lines = split_lines(&unfolded);
if lines.is_empty() {
return Err(Error::parse(1, "empty input"));
}
let mut line_idx = 0;
let mut cal = Calendar::new();
let first = parse_content_line(lines[line_idx], line_idx + 1)?;
if first.name != "BEGIN" || first.value.to_uppercase() != "VCALENDAR" {
return Err(Error::parse(
1,
format!(
"expected BEGIN:VCALENDAR, found {}:{}",
first.name, first.value
),
));
}
line_idx += 1;
while line_idx < lines.len() {
let prop = parse_content_line(lines[line_idx], line_idx + 1)?;
line_idx += 1;
match (prop.name.as_str(), prop.value.to_uppercase().as_str()) {
("BEGIN", "VEVENT") => {
let (event, new_idx) = parse_event(&lines, line_idx)?;
cal.events.push(event);
line_idx = new_idx;
}
("BEGIN", "VTODO") => {
let (todo, new_idx) = parse_todo(&lines, line_idx)?;
cal.todos.push(todo);
line_idx = new_idx;
}
("BEGIN", _) => {
let (extra_props, new_idx) = skip_component(&prop.value, &lines, line_idx)?;
cal.extra_properties
.push(Property::new("BEGIN", &prop.value));
cal.extra_properties.extend(extra_props);
cal.extra_properties.push(Property::new("END", &prop.value));
line_idx = new_idx;
}
("END", "VCALENDAR") => {
return Ok(cal);
}
("VERSION", _) => {
cal.version = prop.value.clone();
}
("PRODID", _) => {
cal.prodid = prop.value.clone();
}
("CALSCALE", _) => {
cal.calscale = Some(prop.value.clone());
}
("METHOD", _) => {
cal.method = Some(prop.value.clone());
}
("X-WR-CALNAME", _) => {
cal.name = Some(prop.value.clone());
}
_ => {
cal.extra_properties.push(prop);
}
}
}
Err(Error::UnclosedComponent("VCALENDAR".to_string()))
}
fn parse_event(lines: &[&str], start: usize) -> Result<(Event, usize)> {
let mut idx = start;
let mut props = Vec::new();
let mut alarms = Vec::new();
while idx < lines.len() {
let prop = parse_content_line(lines[idx], idx + 1)?;
idx += 1;
match (prop.name.as_str(), prop.value.to_uppercase().as_str()) {
("BEGIN", "VALARM") => {
let (alarm, new_idx) = parse_alarm(lines, idx)?;
alarms.push(alarm);
idx = new_idx;
}
("END", "VEVENT") => {
let event = Event::from_properties(props, alarms)?;
return Ok((event, idx));
}
_ => {
props.push(prop);
}
}
}
Err(Error::UnclosedComponent("VEVENT".to_string()))
}
fn parse_todo(lines: &[&str], start: usize) -> Result<(Todo, usize)> {
let mut idx = start;
let mut props = Vec::new();
let mut alarms = Vec::new();
while idx < lines.len() {
let prop = parse_content_line(lines[idx], idx + 1)?;
idx += 1;
match (prop.name.as_str(), prop.value.to_uppercase().as_str()) {
("BEGIN", "VALARM") => {
let (alarm, new_idx) = parse_alarm(lines, idx)?;
alarms.push(alarm);
idx = new_idx;
}
("END", "VTODO") => {
let todo = Todo::from_properties(props, alarms)?;
return Ok((todo, idx));
}
_ => {
props.push(prop);
}
}
}
Err(Error::UnclosedComponent("VTODO".to_string()))
}
fn parse_alarm(lines: &[&str], start: usize) -> Result<(Alarm, usize)> {
let mut idx = start;
let mut action = AlarmAction::Display;
let mut trigger = String::new();
let mut description = None;
let mut summary = None;
let mut duration = None;
let mut repeat = None;
let mut extra = Vec::new();
while idx < lines.len() {
let prop = parse_content_line(lines[idx], idx + 1)?;
idx += 1;
match prop.name.as_str() {
"ACTION" => action = AlarmAction::parse(&prop.value),
"TRIGGER" => trigger = prop.value.clone(),
"DESCRIPTION" => description = Some(prop.value.clone()),
"SUMMARY" => summary = Some(prop.value.clone()),
"DURATION" => duration = Some(prop.value.clone()),
"REPEAT" => repeat = prop.value.parse().ok(),
"END" if prop.value.to_uppercase() == "VALARM" => {
return Ok((
Alarm {
action,
trigger,
description,
summary,
duration,
repeat,
extra_properties: extra,
},
idx,
));
}
_ => extra.push(prop),
}
}
Err(Error::UnclosedComponent("VALARM".to_string()))
}
fn skip_component(name: &str, lines: &[&str], start: usize) -> Result<(Vec<Property>, usize)> {
let mut idx = start;
let mut props = Vec::new();
let end_name = name.to_uppercase();
while idx < lines.len() {
let prop = parse_content_line(lines[idx], idx + 1)?;
idx += 1;
if prop.name == "END" && prop.value.to_uppercase() == end_name {
return Ok((props, idx));
}
props.push(prop);
}
Err(Error::UnclosedComponent(name.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_calendar() {
let input =
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nEND:VCALENDAR\r\n";
let cal = parse_calendar(input).unwrap();
assert_eq!(cal.version, "2.0");
assert_eq!(cal.prodid, "-//Test//Test//EN");
assert!(cal.events.is_empty());
}
#[test]
fn parse_calendar_with_event() {
let input = "\
BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//Test//Test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:test-uid-123\r\n\
DTSTAMP:20260315T090000Z\r\n\
DTSTART:20260315T090000\r\n\
DTEND:20260315T093000\r\n\
SUMMARY:Team Standup\r\n\
LOCATION:Room 42\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let cal = parse_calendar(input).unwrap();
assert_eq!(cal.events.len(), 1);
let event = &cal.events[0];
assert_eq!(event.get_uid(), Some("test-uid-123"));
assert_eq!(event.get_summary(), Some("Team Standup"));
assert_eq!(event.get_location(), Some("Room 42"));
}
#[test]
fn parse_event_with_alarm() {
let input = "\
BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//Test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:test-uid\r\n\
DTSTAMP:20260315T090000Z\r\n\
SUMMARY:Test\r\n\
BEGIN:VALARM\r\n\
ACTION:DISPLAY\r\n\
TRIGGER:-PT15M\r\n\
DESCRIPTION:Reminder\r\n\
END:VALARM\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let cal = parse_calendar(input).unwrap();
assert_eq!(cal.events[0].get_alarms().len(), 1);
assert_eq!(cal.events[0].get_alarms()[0].trigger, "-PT15M");
}
#[test]
fn parse_calendar_with_todo() {
let input = "\
BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//Test//EN\r\n\
BEGIN:VTODO\r\n\
UID:todo-uid\r\n\
DTSTAMP:20260315T090000Z\r\n\
SUMMARY:Buy groceries\r\n\
DUE:20260316\r\n\
STATUS:NEEDS-ACTION\r\n\
END:VTODO\r\n\
END:VCALENDAR\r\n";
let cal = parse_calendar(input).unwrap();
assert_eq!(cal.todos.len(), 1);
assert_eq!(cal.todos[0].get_summary(), Some("Buy groceries"));
assert_eq!(cal.todos[0].get_status(), Some("NEEDS-ACTION"));
}
#[test]
fn parse_unknown_properties_preserved() {
let input = "\
BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//Test//EN\r\n\
X-CUSTOM:custom-value\r\n\
BEGIN:VEVENT\r\n\
UID:test\r\n\
DTSTAMP:20260315T090000Z\r\n\
X-EVENT-CUSTOM:event-custom\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let cal = parse_calendar(input).unwrap();
assert!(
cal.extra_properties
.iter()
.any(|p| p.name == "X-CUSTOM" && p.value == "custom-value")
);
assert!(
cal.events[0]
.extra_properties
.iter()
.any(|p| p.name == "X-EVENT-CUSTOM" && p.value == "event-custom")
);
}
#[test]
fn parse_folded_lines() {
let input = "\
BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//Test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:test\r\n\
DTSTAMP:20260315T090000Z\r\n\
SUMMARY:This is a very long summary that has been folded across \r\n multiple lines in the file\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let cal = parse_calendar(input).unwrap();
assert_eq!(
cal.events[0].get_summary(),
Some(
"This is a very long summary that has been folded across multiple lines in the file"
)
);
}
#[test]
fn parse_escaped_text() {
let input = "\
BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//Test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:test\r\n\
DTSTAMP:20260315T090000Z\r\n\
SUMMARY:Meeting\\, with commas\r\n\
DESCRIPTION:Line 1\\nLine 2\\nLine 3\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let cal = parse_calendar(input).unwrap();
assert_eq!(cal.events[0].get_summary(), Some("Meeting, with commas"));
assert_eq!(
cal.events[0].get_description(),
Some("Line 1\nLine 2\nLine 3")
);
}
#[test]
fn error_missing_vcalendar() {
let result = parse_calendar("BEGIN:VEVENT\r\nEND:VEVENT\r\n");
assert!(result.is_err());
}
#[test]
fn error_unclosed_component() {
let input = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:x\r\n";
let result = parse_calendar(input);
assert!(result.is_err());
}
#[test]
fn parse_with_bare_lf() {
let input = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//EN\nEND:VCALENDAR\n";
let cal = parse_calendar(input).unwrap();
assert_eq!(cal.version, "2.0");
}
#[test]
fn parse_event_with_rrule() {
let input = "\
BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//Test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:recurring\r\n\
DTSTAMP:20260315T090000Z\r\n\
DTSTART:20260315T090000\r\n\
SUMMARY:Weekly standup\r\n\
RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=52\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let cal = parse_calendar(input).unwrap();
let rrule = cal.events[0].get_rrule().unwrap();
assert_eq!(rrule.freq, crate::ical::recurrence::Frequency::Weekly);
assert_eq!(rrule.count, Some(52));
}
#[test]
fn parse_event_with_tzid() {
let input = "\
BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//Test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:tz-event\r\n\
DTSTAMP:20260315T090000Z\r\n\
DTSTART;TZID=America/New_York:20260315T090000\r\n\
DTEND;TZID=America/New_York:20260315T093000\r\n\
SUMMARY:Eastern meeting\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let cal = parse_calendar(input).unwrap();
let start = cal.events[0].get_starts().unwrap();
if let crate::datetime::DateTimeValue::DateTimeTz { tzid, .. } = start {
assert_eq!(tzid, "America/New_York");
} else {
panic!("Expected DateTimeTz, got {:?}", start);
}
}
#[test]
fn parse_categories() {
let input = "\
BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//Test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:cat-event\r\n\
DTSTAMP:20260315T090000Z\r\n\
SUMMARY:Test\r\n\
CATEGORIES:WORK,MEETING,IMPORTANT\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let cal = parse_calendar(input).unwrap();
assert_eq!(
cal.events[0].get_categories(),
&["WORK", "MEETING", "IMPORTANT"]
);
}
}