use std::{
borrow::Cow,
collections::{HashMap, HashSet},
};
use log::warn;
use vparser::{ContentLine, ParamIter, Parser};
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Component<'a> {
kind: Cow<'a, str>,
lines: Vec<ContentLine<'a>>,
subcomponents: Vec<Component<'a>>,
uid: Option<Cow<'a, str>>,
tzid: Option<Cow<'a, str>>,
referenced_tzids: HashSet<String>,
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub(crate) enum ComponentError {
#[error("unknown (or unimplemented) component: {0}")]
UnknownComponent(String),
#[error("found data after END of root component")]
DataAfterEnd,
#[error("reached end of file while parsing data")]
UnexpectedEof,
#[error("unbalanced BEGIN and END lines")]
WrongEnd,
#[error("END line had no matching BEGIN line")]
EndWithoutBegin,
#[error("found data after last END: line")]
DataOutsideBeginEnd,
#[error("VCALENDAR cannot be nested inside other components")]
InvalidStructure,
}
impl<'a> Component<'a> {
fn new(kind: Cow<'a, str>) -> Self {
Component {
kind,
lines: Vec::new(),
subcomponents: Vec::new(),
uid: None,
tzid: None,
referenced_tzids: HashSet::new(),
}
}
pub(crate) fn parse_split(input: &'a str) -> Result<Vec<Component<'a>>, ComponentError> {
let mut state = ParseState::new(input);
while let Some(line) = state.parser.next() {
match line.name().as_ref() {
"BEGIN" => state.handle_begin(&line)?,
"END" => {
if state.handle_end(&line)? {
return Ok(state.finalize());
}
}
_ => state.handle_property(line)?,
}
}
Err(ComponentError::UnexpectedEof)
}
}
struct ParseState<'a> {
parser: Parser<'a>,
input_context: Vec<Cow<'a, str>>,
builder: Option<Builder<'a>>,
timezones: HashMap<String, Component<'a>>,
items: HashMap<Cow<'a, str>, Component<'a>>,
without_uid: Vec<Component<'a>>,
}
impl<'a> ParseState<'a> {
fn new(input: &'a str) -> Self {
ParseState {
parser: Parser::new(input),
input_context: Vec::new(),
builder: None,
timezones: HashMap::new(),
items: HashMap::new(),
without_uid: Vec::new(),
}
}
fn handle_begin(&mut self, line: &ContentLine<'a>) -> Result<(), ComponentError> {
let kind = line.value();
self.input_context.push(kind.clone());
match kind.as_ref() {
"VTIMEZONE" => match &mut self.builder {
Some(_) => return Err(ComponentError::InvalidStructure),
None => self.builder = Some(Builder::Timezone(Component::new(kind))),
},
"VEVENT" | "VTODO" | "VJOURNAL" => {
if self
.builder
.replace(Builder::Item(ItemBuilder::new(kind)))
.is_some()
{
return Err(ComponentError::InvalidStructure);
}
}
"VCALENDAR" => {
if self.builder.is_some() {
return Err(ComponentError::InvalidStructure);
}
}
_ => match &mut self.builder {
Some(builder) => builder.push_subcomponent(Component::new(kind)),
None => return Err(ComponentError::UnknownComponent(kind.to_string())),
},
}
Ok(())
}
fn handle_end(&mut self, line: &ContentLine<'a>) -> Result<bool, ComponentError> {
let kind = line.value();
let expected = self
.input_context
.pop()
.ok_or(ComponentError::EndWithoutBegin)?;
if kind != expected {
return Err(ComponentError::WrongEnd);
}
match kind.as_ref() {
"VTIMEZONE" => match self.builder.take() {
Some(Builder::Timezone(tz)) => match &tz.tzid {
Some(tzid) => {
self.timezones.insert(normalize_tzid(tzid), tz);
}
None => {
warn!("VTIMEZONE component has no TZID property.");
}
},
Some(Builder::Item(_)) => return Err(ComponentError::InvalidStructure),
None => {
unreachable!("input_context would be None if this were None");
}
},
"VEVENT" | "VTODO" | "VJOURNAL" => {
if let Some(Builder::Item(item_builder)) = self.builder.take() {
let wrapper = item_builder.into_item();
match wrapper.subcomponents.first().and_then(|c| c.uid.as_ref()) {
Some(uid) => {
self.items.insert(uid.clone(), wrapper);
}
None => {
self.without_uid.push(wrapper);
}
}
}
}
_ => {
if let Some(Builder::Item(item_builder)) = &mut self.builder {
item_builder.pop_subcomponent();
}
}
}
if self.input_context.is_empty() {
if self.parser.next().is_some_and(|l| !l.raw().is_empty()) {
return Err(ComponentError::DataAfterEnd);
}
return Ok(true);
}
Ok(false)
}
fn handle_property(&mut self, line: ContentLine<'a>) -> Result<(), ComponentError> {
let name = line.name();
match &mut self.builder {
Some(Builder::Timezone(tz)) => {
if name == "TZID" {
tz.tzid = Some(line.value());
}
tz.lines.push(line);
}
Some(Builder::Item(item_builder)) => {
item_builder.process_line(line);
}
None => {
if self.input_context.is_empty() {
return Err(ComponentError::DataOutsideBeginEnd);
}
}
}
Ok(())
}
fn finalize(self) -> Vec<Component<'a>> {
let mut result: Vec<Component<'a>> =
Vec::with_capacity(self.items.len() + self.without_uid.len());
for mut wrapper in self.items.into_values().chain(self.without_uid) {
for tzid in &wrapper.referenced_tzids {
if let Some(tz) = self.timezones.get(tzid) {
wrapper.subcomponents.push(tz.clone());
} else {
warn!("Component references non-existent TZID: {tzid}");
}
}
result.push(wrapper);
}
result
}
}
fn normalize_tzid(value: &str) -> String {
let stripped = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
&value[1..value.len() - 1]
} else {
value
};
stripped.to_lowercase()
}
enum Builder<'a> {
Timezone(Component<'a>),
Item(ItemBuilder<'a>),
}
impl<'a> Builder<'a> {
fn push_subcomponent(&mut self, subcomponent: Component<'a>) {
match self {
Builder::Timezone(tz) => tz.subcomponents.push(subcomponent),
Builder::Item(ib) => ib.push_subcomponent(subcomponent),
}
}
}
struct ItemBuilder<'a> {
wrapper: Component<'a>,
component_stack: Vec<Component<'a>>,
}
impl<'a> ItemBuilder<'a> {
fn new(kind: Cow<'a, str>) -> Self {
let wrapper = Component::new(Cow::Borrowed("VCALENDAR"));
let item = Component::new(kind);
ItemBuilder {
wrapper,
component_stack: vec![item],
}
}
fn push_subcomponent(&mut self, component: Component<'a>) {
self.current().subcomponents.push(component);
}
fn pop_subcomponent(&mut self) {
if let Some(child) = self.component_stack.pop() {
self.current().subcomponents.push(child);
}
}
fn process_line(&mut self, line: ContentLine<'a>) {
let name = line.name();
if name == "UID"
&& self.component_stack.len() == 1
&& let Some(item) = self.component_stack.first_mut()
{
item.uid = Some(line.value());
}
if name == "TZID" {
self.current().tzid = Some(line.value());
}
for param in ParamIter::new(&line.params()) {
if param.name() == "TZID" {
self.wrapper
.referenced_tzids
.insert(normalize_tzid(param.value()));
}
}
self.current().lines.push(line);
}
fn current(&mut self) -> &mut Component<'a> {
self.component_stack
.last_mut()
.expect("component_stack non-empty")
}
fn into_item(self) -> Component<'a> {
let mut wrapper = self.wrapper;
for component in self.component_stack {
wrapper.subcomponents.push(component);
}
wrapper
}
}
impl std::fmt::Display for Component<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "BEGIN:{}\r\n", self.kind)?;
for line in &self.lines {
write!(f, "{}\r\n", line.raw())?;
}
for component in &self.subcomponents {
write!(f, "{component}")?;
}
write!(f, "END:{}\r\n", self.kind)
}
}
#[cfg(test)]
mod test {
use std::collections::HashSet;
use crate::simple_component::ComponentError;
#[test]
fn test_parse_and_split_collection() {
use super::Component;
let calendar = vec![
"BEGIN:VCALENDAR",
"BEGIN:VTIMEZONE",
"TZID:Europe/Rome",
"X-LIC-LOCATION:Europe/Rome",
"BEGIN:DAYLIGHT",
"TZOFFSETFROM:+0100",
"TZOFFSETTO:+0200",
"TZNAME:CEST",
"DTSTART:19700329T020000",
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3",
"END:DAYLIGHT",
"BEGIN:STANDARD",
"TZOFFSETFROM:+0200",
"TZOFFSETTO:+0100",
"TZNAME:CET",
"DTSTART:19701025T030000",
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10",
"END:STANDARD",
"END:VTIMEZONE",
"BEGIN:VEVENT",
"DTSTART:19970714T170000Z",
"DTEND:19970715T035959Z",
"SUMMARY:Bastille Day Party",
"X-SOMETHING:r",
"UID:11bb6bed-c29b-4999-a627-12dee35f8395",
"END:VEVENT",
"BEGIN:VEVENT",
"DTSTART:19970714T170000Z",
"DTEND:19970715T035959Z",
"SUMMARY:Bastille Day Party (copy)",
"X-SOMETHING:s",
"UID:b8d52b8b-dd6b-4ef9-9249-0ad7c28f9e5a",
"END:VEVENT",
"END:VCALENDAR",
]
.join("\r\n");
let serialised_split = Component::parse_split(&calendar)
.unwrap()
.iter()
.map(Component::to_string)
.collect::<Vec<_>>();
let expected_first = [
"BEGIN:VCALENDAR",
"BEGIN:VEVENT",
"DTSTART:19970714T170000Z",
"DTEND:19970715T035959Z",
"SUMMARY:Bastille Day Party (copy)",
"X-SOMETHING:s",
"UID:b8d52b8b-dd6b-4ef9-9249-0ad7c28f9e5a",
"END:VEVENT",
"END:VCALENDAR",
"",
]
.join("\r\n");
let expected_second = [
"BEGIN:VCALENDAR",
"BEGIN:VEVENT",
"DTSTART:19970714T170000Z",
"DTEND:19970715T035959Z",
"SUMMARY:Bastille Day Party",
"X-SOMETHING:r",
"UID:11bb6bed-c29b-4999-a627-12dee35f8395",
"END:VEVENT",
"END:VCALENDAR",
"",
]
.join("\r\n");
assert!(serialised_split.iter().any(|c| **c == expected_first));
assert!(serialised_split.iter().any(|c| **c == expected_second));
}
#[test]
fn test_missing_end() {
use super::Component;
let calendar = [
"BEGIN:VCALENDAR",
"BEGIN:VTIMEZONE",
"TZID:Europe/Rome",
"END:VTIMEZONE",
"BEGIN:VEVENT",
"SUMMARY:This event is probably invalid due to missing fields",
"UID:11bb6bed-c29b-4999-a627-12dee35f8395",
"END:VEVENT",
]
.join("\r\n");
assert_eq!(
Component::parse_split(&calendar),
Err(ComponentError::UnexpectedEof)
);
}
#[test]
fn test_unknown_kind() {
use super::Component;
let calendar = [
"BEGIN:VCALENDAR",
"BEGIN:VTIMEZONE",
"TZID:Europe/Rome",
"END:VTIMEZONE",
"BEGIN:VEVENT",
"SUMMARY:This event is probably invalid due to missing fields",
"UID:11bb6bed-c29b-4999-a627-12dee35f8395",
"END:VEVENT",
"BEGIN:VAUTOMOBILE",
"END:VAUTOMOBILE",
"END:VCALENDAR",
]
.join("\r\n");
assert_eq!(
Component::parse_split(&calendar),
Err(ComponentError::UnknownComponent("VAUTOMOBILE".to_string()))
);
}
#[test]
fn test_multiline_uid() {
use super::Component;
let calendar = [
"BEGIN:VCALENDAR",
"BEGIN:VTIMEZONE",
"TZID:Europe/Rome",
"END:VTIMEZONE",
"BEGIN:VEVENT",
"SUMMARY:This event is probably invalid due to missing fields",
"UID:horrible-",
" example",
"END:VEVENT",
"END:VCALENDAR",
]
.join("\r\n");
let calendar = Component::parse_split(&calendar).unwrap().pop().unwrap();
assert_eq!(
calendar.subcomponents[0].uid.as_ref().unwrap(),
"horrible-example"
);
}
#[test]
fn test_splitting_and_including_tzid() {
use super::Component;
let calendar = [
"BEGIN:VCALENDAR",
"BEGIN:VTIMEZONE",
"TZID:Europe/Rome",
"END:VTIMEZONE",
"BEGIN:VTIMEZONE",
"TZID:America/New_York",
"END:VTIMEZONE",
"BEGIN:VTIMEZONE",
"TZID:Pacific/Honolulu", "END:VTIMEZONE",
"BEGIN:VEVENT",
"DTSTART;TZID=Europe/Rome:19970714T170000", "UID:event-unquoted",
"END:VEVENT",
"BEGIN:VEVENT",
"DTSTART;TZID=\"America/New_York\":19970714T170000", "UID:event-quoted",
"END:VEVENT",
"BEGIN:VEVENT",
"DTSTART;TZID=Europe/Rome:19970714T090000",
"DTEND;TZID=\"America/New_York\":19970714T170000", "UID:event-both",
"END:VEVENT",
"END:VCALENDAR",
]
.join("\r\n");
let components = Component::parse_split(&calendar).unwrap();
let tzids_unquoted: Vec<_> = components
.iter()
.find(|c| {
c.subcomponents
.iter()
.any(|s| s.uid.as_deref() == Some("event-unquoted"))
})
.unwrap()
.subcomponents
.iter()
.filter_map(|c| c.tzid.as_deref())
.collect();
assert_eq!(tzids_unquoted, vec!["Europe/Rome"]);
let tzids_quoted: Vec<_> = components
.iter()
.find(|c| {
c.subcomponents
.iter()
.any(|s| s.uid.as_deref() == Some("event-quoted"))
})
.unwrap()
.subcomponents
.iter()
.filter_map(|c| c.tzid.as_deref())
.collect();
assert_eq!(tzids_quoted, vec!["America/New_York"]);
let tzids_both: HashSet<_> = components
.iter()
.find(|c| {
c.subcomponents
.iter()
.any(|s| s.uid.as_deref() == Some("event-both"))
})
.unwrap()
.subcomponents
.iter()
.filter_map(|c| c.tzid.as_deref())
.collect();
assert!(tzids_both.contains("Europe/Rome"));
assert!(tzids_both.contains("America/New_York"));
assert_eq!(tzids_both.len(), 2);
for component in &components {
assert!(!component.to_string().contains("Pacific/Honolulu"));
}
}
#[test]
fn test_data_after_end() {
use super::Component;
let calendar = [
"BEGIN:VCALENDAR",
"BEGIN:VEVENT",
"UID:test-event",
"END:VEVENT",
"END:VCALENDAR",
"BEGIN:VCALENDAR", "BEGIN:VEVENT",
"UID:orphan-event",
"END:VEVENT",
"END:VCALENDAR",
]
.join("\r\n");
assert_eq!(
Component::parse_split(&calendar),
Err(ComponentError::DataAfterEnd)
);
}
}