1pub mod escape;
2pub mod line;
3pub mod unfold;
4
5use crate::error::{Error, Result};
6use crate::event::{EventClass, Transp, VEvent};
7use crate::parser::line::parse_logical_line;
8use crate::profile::microsoft::MsBusyStatus;
9use crate::profile::{google, icloud, microsoft};
10use crate::raw::{RawComponent, RawProperty};
11use crate::vcalendar::VCalendar;
12use chrono::{NaiveDate, NaiveDateTime};
13
14pub fn parse_calendar(content: &str) -> Result<VCalendar> {
23 let logical = unfold::unfold(content);
24 let lines: Vec<&str> = logical.iter().map(|s| s.trim()).collect();
25 let mut idx = 0;
26
27 while idx < lines.len() && lines[idx] != "BEGIN:VCALENDAR" {
30 idx += 1;
31 }
32 if idx == lines.len() {
33 return Err(Error::parse("missing BEGIN:VCALENDAR"));
34 }
35 idx += 1; let mut version = String::new();
38 let mut prodid = String::new();
39 let mut calscale: Option<String> = None;
40 let mut method: Option<String> = None;
41 let mut events: Vec<VEvent> = Vec::new();
42 let mut unrecognized_components: Vec<RawComponent> = Vec::new();
43
44 while idx < lines.len() {
45 let line = lines[idx];
46 if line == "END:VCALENDAR" {
47 break;
48 }
49 if let Some(name) = strip_begin(line) {
50 if name == "VEVENT" {
51 let (event, next) = parse_vevent_block(&lines, idx + 1)?;
52 events.push(event);
53 idx = next;
54 continue;
55 }
56 let (comp, next) = parse_raw_component_block(name, &lines, idx + 1);
57 unrecognized_components.push(comp);
58 idx = next;
59 continue;
60 }
61 if let Some(v) = line.strip_prefix("VERSION:") {
63 version = v.to_string();
64 } else if let Some(v) = line.strip_prefix("PRODID:") {
65 prodid = v.to_string();
66 } else if let Some(v) = line.strip_prefix("CALSCALE:") {
67 calscale = Some(v.to_string());
68 } else if let Some(v) = line.strip_prefix("METHOD:") {
69 method = Some(v.to_string());
70 }
71 idx += 1;
75 }
76
77 Ok(VCalendar {
78 version,
79 prodid,
80 calscale,
81 method,
82 events,
83 unrecognized_components,
84 })
85}
86
87pub fn parse_events(content: &str) -> Result<Vec<VEvent>> {
89 parse_calendar(content).map(|c| c.events)
90}
91
92fn strip_begin(line: &str) -> Option<&str> {
93 line.strip_prefix("BEGIN:")
94}
95
96fn strip_end(line: &str) -> Option<&str> {
97 line.strip_prefix("END:")
98}
99
100fn parse_vevent_block(lines: &[&str], start: usize) -> Result<(VEvent, usize)> {
104 let mut uid = String::new();
105 let mut dtstamp: Option<NaiveDateTime> = None;
106 let mut dtstart: Option<NaiveDate> = None;
107 let mut dtend: Option<NaiveDate> = None;
108 let mut summary = String::new();
109 let mut transp: Option<Transp> = None;
110 let mut ms_busystatus: Option<MsBusyStatus> = None;
111 let mut class: Option<EventClass> = None;
112 let mut categories: Vec<String> = Vec::new();
113 let mut unknown: Vec<RawProperty> = Vec::new();
114 let mut ms_unrecognized: Vec<RawProperty> = Vec::new();
115 let mut google_unrecognized: Vec<RawProperty> = Vec::new();
116 let mut icloud_unrecognized: Vec<RawProperty> = Vec::new();
117 let mut x_index: u32 = 0;
121 let mut unrecognized_components: Vec<RawComponent> = Vec::new();
122
123 let mut idx = start;
124 while idx < lines.len() {
125 let line = lines[idx];
126 let line_no = (idx + 1) as u32;
127 if line == "END:VEVENT" {
128 let stamp =
129 dtstamp.ok_or_else(|| Error::parse_at_line(line_no, "VEVENT missing DTSTAMP"))?;
130 let s =
131 dtstart.ok_or_else(|| Error::parse_at_line(line_no, "VEVENT missing DTSTART"))?;
132 let e = dtend.ok_or_else(|| Error::parse_at_line(line_no, "VEVENT missing DTEND"))?;
133 return Ok((
134 VEvent {
135 uid,
136 dtstamp: stamp,
137 dtstart: s,
138 dtend: e,
139 summary,
140 transp,
141 class,
142 categories,
143 microsoft: if ms_busystatus.is_some() || !ms_unrecognized.is_empty() {
144 Some(microsoft::EventExtensions {
145 busystatus: ms_busystatus,
146 unrecognized: ms_unrecognized,
147 })
148 } else {
149 None
150 },
151 google: if !google_unrecognized.is_empty() {
152 Some(google::EventExtensions {
153 unrecognized: google_unrecognized,
154 })
155 } else {
156 None
157 },
158 icloud: if !icloud_unrecognized.is_empty() {
159 Some(icloud::EventExtensions {
160 unrecognized: icloud_unrecognized,
161 })
162 } else {
163 None
164 },
165 unknown,
166 unrecognized_components,
167 },
168 idx + 1,
169 ));
170 }
171 if let Some(name) = strip_begin(line) {
172 let (comp, next) = parse_raw_component_block(name, lines, idx + 1);
173 unrecognized_components.push(comp);
174 idx = next;
175 continue;
176 }
177 if let Some(ll) = parse_logical_line(line) {
178 match ll.name.as_str() {
179 "UID" => uid = ll.value.to_string(),
180 "DTSTAMP" => {
181 dtstamp = Some(
182 NaiveDateTime::parse_from_str(ll.value, "%Y%m%dT%H%M%SZ").map_err(|e| {
183 Error::parse_at(line_no, "DTSTAMP", format!("Invalid DTSTAMP: {e}"))
184 })?,
185 );
186 }
187 "DTSTART" => {
188 if has_value_date_param(&ll.params) {
189 dtstart =
190 Some(NaiveDate::parse_from_str(ll.value, "%Y%m%d").map_err(|e| {
191 Error::parse_at(line_no, "DTSTART", format!("Invalid DTSTART: {e}"))
192 })?);
193 }
194 }
197 "DTEND" => {
198 if has_value_date_param(&ll.params) {
199 dtend =
200 Some(NaiveDate::parse_from_str(ll.value, "%Y%m%d").map_err(|e| {
201 Error::parse_at(line_no, "DTEND", format!("Invalid DTEND: {e}"))
202 })?);
203 }
204 }
205 "SUMMARY" => summary = escape::decode_text(ll.value),
206 "TRANSP" => transp = Transp::from_ics(ll.value),
207 "X-MICROSOFT-CDO-BUSYSTATUS" => {
208 if let Some(bs) = MsBusyStatus::from_cdo(ll.value) {
209 ms_busystatus = Some(bs);
210 }
211 }
212 "CLASS" => class = EventClass::from_ics(ll.value),
213 "CATEGORIES" => {
214 categories = escape::split_text_list(ll.value)
215 .into_iter()
216 .map(|s| s.trim().to_string())
217 .collect();
218 }
219 name if name.starts_with("X-") => {
220 x_index += 1;
221 let prop = ll.to_raw_property(x_index);
222 if microsoft::owns_property(&prop.name) {
223 ms_unrecognized.push(prop);
224 } else if google::owns_property(&prop.name) {
225 google_unrecognized.push(prop);
226 } else if icloud::owns_property(&prop.name) {
227 icloud_unrecognized.push(prop);
228 } else {
229 unknown.push(prop);
230 }
231 }
232 _ => {
233 }
236 }
237 }
238 idx += 1;
239 }
240 Err(Error::parse("VEVENT missing END:VEVENT"))
241}
242
243fn has_value_date_param(params: &[(String, String)]) -> bool {
245 params.iter().any(|(k, v)| k == "VALUE" && v == "DATE")
246}
247
248fn parse_raw_component_block(name: &str, lines: &[&str], start: usize) -> (RawComponent, usize) {
253 let name = name.to_uppercase();
254 let mut properties: Vec<RawProperty> = Vec::new();
255 let mut sub_components: Vec<RawComponent> = Vec::new();
256 let mut prop_index: u32 = 0;
257 let mut idx = start;
258 while idx < lines.len() {
259 let line = lines[idx];
260 if let Some(end_name) = strip_end(line) {
261 if end_name.eq_ignore_ascii_case(&name) {
262 return (
263 RawComponent {
264 name,
265 properties,
266 sub_components,
267 },
268 idx + 1,
269 );
270 }
271 return (
274 RawComponent {
275 name,
276 properties,
277 sub_components,
278 },
279 idx,
280 );
281 }
282 if let Some(sub_name) = strip_begin(line) {
283 let (sub, next) = parse_raw_component_block(sub_name, lines, idx + 1);
284 sub_components.push(sub);
285 idx = next;
286 continue;
287 }
288 if let Some(prop) = parse_raw_property(line, prop_index + 1) {
289 properties.push(prop);
290 prop_index += 1;
291 }
292 idx += 1;
293 }
294 (
296 RawComponent {
297 name,
298 properties,
299 sub_components,
300 },
301 idx,
302 )
303}
304
305pub(crate) fn parse_raw_property(line: &str, source_index: u32) -> Option<RawProperty> {
312 parse_logical_line(line).map(|ll| ll.to_raw_property(source_index))
313}
314
315pub fn parse_indices(input: &str, max: usize) -> Result<Vec<usize>> {
318 let mut indices = Vec::new();
319 for part in input.split(',') {
320 let part = part.trim();
321 if let Some((start, end)) = part.split_once('-') {
322 let s: usize = start
323 .trim()
324 .parse()
325 .map_err(|_| Error::parse(format!("Invalid number: {start}")))?;
326 let e: usize = end
327 .trim()
328 .parse()
329 .map_err(|_| Error::parse(format!("Invalid number: {end}")))?;
330 if s == 0 || e == 0 || s > max || e > max {
331 return Err(Error::parse(format!("Index out of range (1-{max})")));
332 }
333 if s > e {
334 return Err(Error::parse(format!("Invalid range: {s}-{e}")));
335 }
336 indices.extend(s..=e);
337 } else {
338 let idx: usize = part
339 .parse()
340 .map_err(|_| Error::parse(format!("Invalid number: {part}")))?;
341 if idx == 0 || idx > max {
342 return Err(Error::parse(format!("Index {idx} out of range (1-{max})")));
343 }
344 indices.push(idx);
345 }
346 }
347 indices.sort();
348 indices.dedup();
349 Ok(indices)
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::calendar::format_calendar;
356 use crate::event::EventClass;
357 use crate::profile::microsoft::{EventExtensions as MsExtensions, MsBusyStatus};
358 use crate::raw::{RawComponent, RawProperty};
359 use crate::test_helpers::make_event;
360 use crate::vcalendar::VCalendar;
361
362 fn vcal(events: Vec<VEvent>) -> VCalendar {
363 VCalendar {
364 events,
365 ..VCalendar::new("-//makeholiday//EN")
366 }
367 }
368
369 #[test]
370 fn parse_roundtrip_with_busystatus_and_class() {
371 let mut event = make_event("rt-bs", (2026, 5, 1), (2026, 5, 2), "出張");
372 event.microsoft = Some(MsExtensions {
373 busystatus: Some(MsBusyStatus::WorkingElsewhere),
374 unrecognized: vec![],
375 });
376 event.class = Some(EventClass::Confidential);
377 let cal = format_calendar(&vcal(vec![event.clone()]));
378 let parsed = parse_calendar(&cal).unwrap();
379 assert_eq!(parsed.events.len(), 1);
380 assert_eq!(
381 parsed.events[0]
382 .microsoft
383 .as_ref()
384 .and_then(|m| m.busystatus),
385 Some(MsBusyStatus::WorkingElsewhere)
386 );
387 assert_eq!(parsed.events[0].class, Some(EventClass::Confidential));
388 }
389
390 #[test]
391 fn parse_events_roundtrip() {
392 let event = make_event("rt-1", (2026, 5, 3), (2026, 5, 4), "憲法記念日");
397 let cal = format_calendar(&vcal(vec![event.clone()]));
398 let parsed = parse_calendar(&cal).unwrap();
399 assert_eq!(parsed.events.len(), 1);
400 assert_eq!(parsed.events[0], event);
401 }
402
403 #[test]
404 fn parse_events_empty() {
405 let cal = format_calendar(&vcal(vec![]));
406 let parsed = parse_calendar(&cal).unwrap();
407 assert!(parsed.events.is_empty());
408 }
409
410 #[test]
411 fn parse_indices_single() {
412 assert_eq!(parse_indices("3", 5).unwrap(), vec![3]);
413 }
414
415 #[test]
416 fn parse_indices_comma() {
417 assert_eq!(parse_indices("4,6", 10).unwrap(), vec![4, 6]);
418 }
419
420 #[test]
421 fn parse_indices_range() {
422 assert_eq!(parse_indices("6-10", 12).unwrap(), vec![6, 7, 8, 9, 10]);
423 }
424
425 #[test]
426 fn parse_indices_mixed() {
427 assert_eq!(parse_indices("1,3-5,8", 10).unwrap(), vec![1, 3, 4, 5, 8]);
428 }
429
430 #[test]
431 fn parse_indices_dedup() {
432 assert_eq!(parse_indices("3,3,3", 5).unwrap(), vec![3]);
433 }
434
435 #[test]
436 fn parse_indices_out_of_range() {
437 assert!(parse_indices("0", 5).is_err());
438 assert!(parse_indices("6", 5).is_err());
439 }
440
441 #[test]
442 fn parse_indices_invalid_range() {
443 assert!(parse_indices("5-3", 10).is_err());
444 }
445
446 #[test]
449 fn unknown_x_property_round_trips() {
450 let mut event = make_event("rt-unk", (2026, 4, 29), (2026, 4, 30), "昭和の日");
451 event.unknown.push(RawProperty {
452 name: "X-CUSTOM-COLOR".to_string(),
453 params: vec![],
454 value: "blue".to_string(),
455 source_index: 1,
456 });
457 let cal = format_calendar(&vcal(vec![event.clone()]));
458 let parsed = parse_calendar(&cal).unwrap();
459 assert_eq!(parsed.events[0].unknown.len(), 1);
460 assert_eq!(parsed.events[0].unknown[0].name, "X-CUSTOM-COLOR");
461 assert_eq!(parsed.events[0].unknown[0].value, "blue");
462 }
463
464 #[test]
465 fn unknown_x_property_with_params_round_trips() {
466 let mut event = make_event("rt-unk-p", (2026, 4, 29), (2026, 4, 30), "昭和の日");
467 event.unknown.push(RawProperty {
468 name: "X-CUSTOM-FOO".to_string(),
469 params: vec![("LANG".to_string(), "en".to_string())],
470 value: "hello".to_string(),
471 source_index: 1,
472 });
473 let cal = format_calendar(&vcal(vec![event.clone()]));
474 let parsed = parse_calendar(&cal).unwrap();
475 assert_eq!(parsed.events[0].unknown.len(), 1);
476 assert_eq!(parsed.events[0].unknown[0].name, "X-CUSTOM-FOO");
477 assert_eq!(
478 parsed.events[0].unknown[0].params,
479 vec![("LANG".to_string(), "en".to_string())]
480 );
481 assert_eq!(parsed.events[0].unknown[0].value, "hello");
482 }
483
484 #[test]
485 fn unknown_x_property_preserves_source_index_order() {
486 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
487 input.push_str("BEGIN:VEVENT\r\n");
488 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
489 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
490 input.push_str("SUMMARY:s\r\n");
491 input.push_str("X-CUSTOM-A:1\r\n");
492 input.push_str("X-CUSTOM-B:2\r\n");
493 input.push_str("X-CUSTOM-C:3\r\n");
494 input.push_str("END:VEVENT\r\n");
495 input.push_str("END:VCALENDAR\r\n");
496 let parsed = parse_calendar(&input).unwrap();
497 assert_eq!(parsed.events[0].unknown.len(), 3);
498 assert_eq!(parsed.events[0].unknown[0].source_index, 1);
499 assert_eq!(parsed.events[0].unknown[0].name, "X-CUSTOM-A");
500 assert_eq!(parsed.events[0].unknown[2].source_index, 3);
501 assert_eq!(parsed.events[0].unknown[2].name, "X-CUSTOM-C");
502 }
503
504 #[test]
505 fn x_microsoft_stays_typed_x_makeholiday_lands_in_unknown() {
506 let mut event = make_event("rt-typed", (2026, 4, 29), (2026, 4, 30), "昭和の日");
510 event.microsoft = Some(MsExtensions {
511 busystatus: Some(MsBusyStatus::Oof),
512 unrecognized: vec![],
513 });
514 event.unknown.push(RawProperty {
515 name: "X-MAKEHOLIDAY-ICON".to_string(),
516 params: vec![],
517 value: "flag".to_string(),
518 source_index: 1,
519 });
520 let cal = format_calendar(&vcal(vec![event.clone()]));
521 let parsed = parse_calendar(&cal).unwrap();
522 assert_eq!(
523 parsed.events[0]
524 .microsoft
525 .as_ref()
526 .and_then(|m| m.busystatus),
527 Some(MsBusyStatus::Oof)
528 );
529 let icon = parsed.events[0]
530 .unknown
531 .iter()
532 .find(|p| p.name == "X-MAKEHOLIDAY-ICON")
533 .map(|p| p.value.as_str());
534 assert_eq!(icon, Some("flag"));
535 }
536
537 #[test]
538 fn parse_raw_property_uppercases_name_and_keys() {
539 let p = parse_raw_property("x-custom-foo;lang=en:hello", 1).unwrap();
540 assert_eq!(p.name, "X-CUSTOM-FOO");
541 assert_eq!(p.params, vec![("LANG".to_string(), "en".to_string())]);
542 assert_eq!(p.value, "hello");
543 }
544
545 #[test]
546 fn parse_raw_property_strips_quotes_from_param_value() {
547 let p = parse_raw_property(r#"X-FOO;LANG="ja-JP":val"#, 1).unwrap();
548 assert_eq!(p.params, vec![("LANG".to_string(), "ja-JP".to_string())]);
549 }
550
551 #[test]
552 fn parse_raw_property_returns_none_when_no_colon() {
553 assert!(parse_raw_property("X-NOCOLON", 1).is_none());
554 }
555
556 #[test]
557 fn class_categories_not_starting_with_x_do_not_fall_to_unknown() {
558 let mut event = make_event("rt-tc", (2026, 4, 29), (2026, 4, 30), "s");
559 event.class = Some(EventClass::Private);
560 event.categories = vec!["work".to_string()];
561 let cal = format_calendar(&vcal(vec![event.clone()]));
562 let parsed = parse_calendar(&cal).unwrap();
563 assert_eq!(parsed.events[0].class, Some(EventClass::Private));
564 assert_eq!(parsed.events[0].categories, vec!["work".to_string()]);
565 assert!(parsed.events[0].unknown.is_empty());
566 }
567
568 #[test]
571 fn vtimezone_round_trips_into_calendar_unrecognized_components() {
572 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
573 input.push_str("BEGIN:VTIMEZONE\r\n");
574 input.push_str("TZID:Asia/Tokyo\r\n");
575 input.push_str("BEGIN:STANDARD\r\n");
576 input.push_str("DTSTART:19700101T000000\r\n");
577 input.push_str("TZOFFSETFROM:+0900\r\n");
578 input.push_str("TZOFFSETTO:+0900\r\n");
579 input.push_str("TZNAME:JST\r\n");
580 input.push_str("END:STANDARD\r\n");
581 input.push_str("END:VTIMEZONE\r\n");
582 input.push_str("END:VCALENDAR\r\n");
583 let parsed = parse_calendar(&input).unwrap();
584 assert_eq!(parsed.unrecognized_components.len(), 1);
585 let tz = &parsed.unrecognized_components[0];
586 assert_eq!(tz.name, "VTIMEZONE");
587 assert_eq!(tz.properties.len(), 1);
588 assert_eq!(tz.properties[0].name, "TZID");
589 assert_eq!(tz.properties[0].value, "Asia/Tokyo");
590 assert_eq!(tz.sub_components.len(), 1);
591 assert_eq!(tz.sub_components[0].name, "STANDARD");
592 assert_eq!(tz.sub_components[0].properties.len(), 4);
593 }
594
595 #[test]
596 fn valarm_round_trips_into_event_unrecognized_components() {
597 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
598 input.push_str("BEGIN:VEVENT\r\n");
599 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
600 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
601 input.push_str("SUMMARY:s\r\n");
602 input.push_str("BEGIN:VALARM\r\n");
603 input.push_str("ACTION:DISPLAY\r\n");
604 input.push_str("TRIGGER:-PT15M\r\n");
605 input.push_str("DESCRIPTION:reminder\r\n");
606 input.push_str("END:VALARM\r\n");
607 input.push_str("END:VEVENT\r\n");
608 input.push_str("END:VCALENDAR\r\n");
609 let parsed = parse_calendar(&input).unwrap();
610 assert_eq!(parsed.events.len(), 1);
611 let event = &parsed.events[0];
612 assert_eq!(event.unrecognized_components.len(), 1);
613 let alarm = &event.unrecognized_components[0];
614 assert_eq!(alarm.name, "VALARM");
615 assert_eq!(alarm.properties.len(), 3);
616 let names: Vec<_> = alarm.properties.iter().map(|p| p.name.as_str()).collect();
617 assert_eq!(names, vec!["ACTION", "TRIGGER", "DESCRIPTION"]);
618 }
619
620 #[test]
623 fn transp_field_parses_from_input() {
624 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
625 input.push_str("BEGIN:VEVENT\r\n");
626 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
627 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
628 input.push_str("SUMMARY:s\r\n");
629 input.push_str("TRANSP:OPAQUE\r\n");
630 input.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
631 let parsed = parse_calendar(&input).unwrap();
632 assert_eq!(parsed.events[0].transp, Some(crate::Transp::Opaque));
633 }
634
635 #[test]
636 fn transp_field_overrides_busystatus_derived_transp_on_output() {
637 let mut event = make_event("transp-override", (2026, 4, 29), (2026, 4, 30), "s");
640 event.microsoft = Some(MsExtensions {
641 busystatus: Some(MsBusyStatus::Oof),
642 unrecognized: vec![], });
644 event.transp = Some(crate::Transp::Transparent); let cal = format_calendar(&vcal(vec![event]));
646 assert!(cal.contains("TRANSP:TRANSPARENT\r\n"));
647 assert!(cal.contains("X-MICROSOFT-CDO-BUSYSTATUS:OOF\r\n"));
648 }
649
650 #[test]
651 fn transp_none_falls_back_to_microsoft_busystatus_derived_value() {
652 let mut event = make_event("transp-fallback", (2026, 4, 29), (2026, 4, 30), "s");
653 event.microsoft = Some(MsExtensions {
654 busystatus: Some(MsBusyStatus::Oof),
655 unrecognized: vec![], });
657 event.transp = None;
658 let cal = format_calendar(&vcal(vec![event]));
659 assert!(cal.contains("TRANSP:OPAQUE\r\n"));
660 }
661
662 #[test]
663 fn no_microsoft_and_no_transp_omits_both_lines() {
664 let event = make_event("transp-nothing", (2026, 4, 29), (2026, 4, 30), "s");
665 let cal = format_calendar(&vcal(vec![event]));
666 assert!(!cal.contains("TRANSP:"));
667 assert!(!cal.contains("X-MICROSOFT-CDO-BUSYSTATUS:"));
668 }
669
670 #[test]
671 fn transp_round_trip_preserves_typed_value() {
672 let mut event = make_event("transp-rt", (2026, 4, 29), (2026, 4, 30), "s");
673 event.transp = Some(crate::Transp::Opaque);
674 let cal = format_calendar(&vcal(vec![event.clone()]));
675 let parsed = parse_calendar(&cal).unwrap();
676 assert_eq!(parsed.events[0].transp, Some(crate::Transp::Opaque));
677 }
678
679 #[test]
682 fn x_microsoft_prefix_routes_to_microsoft_unrecognized_not_unknown() {
683 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
684 input.push_str("BEGIN:VEVENT\r\n");
685 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
686 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
687 input.push_str("SUMMARY:s\r\n");
688 input.push_str("X-MICROSOFT-CDO-ALLDAYEVENT:TRUE\r\n");
689 input.push_str("X-MICROSOFT-IMPORTANCE:1\r\n");
690 input.push_str("X-CUSTOM-COLOR:blue\r\n");
691 input.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
692 let parsed = parse_calendar(&input).unwrap();
693 let event = &parsed.events[0];
694
695 let ms = event.microsoft.as_ref().unwrap();
697 assert_eq!(ms.busystatus, None); assert_eq!(ms.unrecognized.len(), 2);
699 let ms_names: Vec<_> = ms.unrecognized.iter().map(|p| p.name.as_str()).collect();
700 assert_eq!(
701 ms_names,
702 vec!["X-MICROSOFT-CDO-ALLDAYEVENT", "X-MICROSOFT-IMPORTANCE"]
703 );
704
705 assert_eq!(event.unknown.len(), 1);
707 assert_eq!(event.unknown[0].name, "X-CUSTOM-COLOR");
708 }
709
710 #[test]
711 fn x_microsoft_cdo_busystatus_still_promotes_to_typed_field_not_unrecognized() {
712 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
713 input.push_str("BEGIN:VEVENT\r\n");
714 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
715 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
716 input.push_str("SUMMARY:s\r\n");
717 input.push_str("X-MICROSOFT-CDO-BUSYSTATUS:OOF\r\n");
718 input.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
719 let parsed = parse_calendar(&input).unwrap();
720 let ms = parsed.events[0].microsoft.as_ref().unwrap();
721 assert_eq!(ms.busystatus, Some(MsBusyStatus::Oof));
722 assert!(ms.unrecognized.is_empty());
723 }
724
725 #[test]
726 fn microsoft_unrecognized_round_trips_through_format() {
727 let mut event = make_event("rt-ms-unrec", (2026, 4, 29), (2026, 4, 30), "s");
728 event.microsoft = Some(MsExtensions {
729 busystatus: None,
730 unrecognized: vec![RawProperty {
731 name: "X-MICROSOFT-CDO-ALLDAYEVENT".to_string(),
732 params: vec![],
733 value: "TRUE".to_string(),
734 source_index: 1,
735 }],
736 });
737 let cal = format_calendar(&vcal(vec![event.clone()]));
738 assert!(cal.contains("X-MICROSOFT-CDO-ALLDAYEVENT:TRUE\r\n"));
739 let parsed = parse_calendar(&cal).unwrap();
740 let ms = parsed.events[0].microsoft.as_ref().unwrap();
741 assert_eq!(ms.unrecognized.len(), 1);
742 assert_eq!(ms.unrecognized[0].name, "X-MICROSOFT-CDO-ALLDAYEVENT");
743 assert_eq!(ms.unrecognized[0].value, "TRUE");
744 }
745
746 #[test]
747 fn source_index_is_monotonic_across_buckets() {
748 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
751 input.push_str("BEGIN:VEVENT\r\n");
752 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
753 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
754 input.push_str("SUMMARY:s\r\n");
755 input.push_str("X-CUSTOM-A:1\r\n");
756 input.push_str("X-MICROSOFT-FOO:2\r\n");
757 input.push_str("X-CUSTOM-B:3\r\n");
758 input.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
759 let parsed = parse_calendar(&input).unwrap();
760 let event = &parsed.events[0];
761 assert_eq!(event.unknown[0].name, "X-CUSTOM-A");
762 assert_eq!(event.unknown[0].source_index, 1);
763 assert_eq!(event.unknown[1].name, "X-CUSTOM-B");
764 assert_eq!(event.unknown[1].source_index, 3);
765 let ms = event.microsoft.as_ref().unwrap();
766 assert_eq!(ms.unrecognized[0].name, "X-MICROSOFT-FOO");
767 assert_eq!(ms.unrecognized[0].source_index, 2);
768 }
769
770 #[test]
771 fn empty_microsoft_bundle_stays_none() {
772 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
773 input.push_str("BEGIN:VEVENT\r\n");
774 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
775 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
776 input.push_str("SUMMARY:s\r\n");
777 input.push_str("X-CUSTOM-A:1\r\n");
778 input.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
779 let parsed = parse_calendar(&input).unwrap();
780 assert!(parsed.events[0].microsoft.is_none());
782 }
783
784 #[test]
787 fn x_google_prefix_routes_to_google_unrecognized() {
788 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
789 input.push_str("BEGIN:VEVENT\r\n");
790 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
791 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
792 input.push_str("SUMMARY:s\r\n");
793 input.push_str("X-GOOGLE-CONFERENCEPROPERTIES:foo\r\n");
794 input.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
795 let parsed = parse_calendar(&input).unwrap();
796 let g = parsed.events[0].google.as_ref().unwrap();
797 assert_eq!(g.unrecognized.len(), 1);
798 assert_eq!(g.unrecognized[0].name, "X-GOOGLE-CONFERENCEPROPERTIES");
799 assert!(parsed.events[0].microsoft.is_none());
800 assert!(parsed.events[0].icloud.is_none());
801 assert!(parsed.events[0].unknown.is_empty());
802 }
803
804 #[test]
805 fn x_apple_and_x_calendarserver_prefixes_route_to_icloud_unrecognized() {
806 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
807 input.push_str("BEGIN:VEVENT\r\n");
808 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
809 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
810 input.push_str("SUMMARY:s\r\n");
811 input.push_str("X-APPLE-CALENDAR-COLOR:#FF0000\r\n");
812 input.push_str("X-CALENDARSERVER-ACCESS:CONFIDENTIAL\r\n");
813 input.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
814 let parsed = parse_calendar(&input).unwrap();
815 let ic = parsed.events[0].icloud.as_ref().unwrap();
816 assert_eq!(ic.unrecognized.len(), 2);
817 let names: Vec<_> = ic.unrecognized.iter().map(|p| p.name.as_str()).collect();
818 assert_eq!(
819 names,
820 vec!["X-APPLE-CALENDAR-COLOR", "X-CALENDARSERVER-ACCESS"]
821 );
822 assert!(parsed.events[0].google.is_none());
823 assert!(parsed.events[0].unknown.is_empty());
824 }
825
826 #[test]
827 fn all_three_vendor_buckets_round_trip_together() {
828 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
829 input.push_str("BEGIN:VEVENT\r\n");
830 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
831 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
832 input.push_str("SUMMARY:s\r\n");
833 input.push_str("X-MICROSOFT-CDO-ALLDAYEVENT:TRUE\r\n");
834 input.push_str("X-GOOGLE-X:1\r\n");
835 input.push_str("X-APPLE-Y:2\r\n");
836 input.push_str("X-CUSTOM-Z:3\r\n");
837 input.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
838 let parsed = parse_calendar(&input).unwrap();
839 let event = &parsed.events[0];
840 assert_eq!(event.microsoft.as_ref().unwrap().unrecognized.len(), 1);
841 assert_eq!(event.google.as_ref().unwrap().unrecognized.len(), 1);
842 assert_eq!(event.icloud.as_ref().unwrap().unrecognized.len(), 1);
843 assert_eq!(event.unknown.len(), 1);
844
845 let cal = format_calendar(&vcal(vec![event.clone()]));
846 let reparsed = parse_calendar(&cal).unwrap();
847 assert_eq!(reparsed.events[0], *event);
848 }
849
850 #[test]
851 fn vendor_bundles_stay_none_when_no_matching_prefix_seen() {
852 let event = make_event("rt-none", (2026, 4, 29), (2026, 4, 30), "s");
853 let cal = format_calendar(&vcal(vec![event]));
854 let parsed = parse_calendar(&cal).unwrap();
855 assert!(parsed.events[0].microsoft.is_none());
856 assert!(parsed.events[0].google.is_none());
857 assert!(parsed.events[0].icloud.is_none());
858 }
859
860 #[test]
863 fn parse_calendar_accepts_leading_utf8_bom() {
864 let mut input =
866 String::from("\u{FEFF}BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
867 input.push_str("BEGIN:VEVENT\r\n");
868 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
869 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
870 input.push_str("SUMMARY:s\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
871 let parsed = parse_calendar(&input).unwrap();
872 assert_eq!(parsed.version, "2.0");
873 assert_eq!(parsed.events.len(), 1);
874 assert_eq!(parsed.events[0].summary, "s");
875 }
876
877 #[test]
878 fn parse_calendar_reassembles_folded_summary() {
879 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
881 input.push_str("BEGIN:VEVENT\r\n");
882 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
883 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
884 input.push_str("SUMMARY:This is a very long event title that has been\r\n");
885 input.push_str(" folded across multiple physical lines per RFC 5545\r\n");
886 input.push_str(" section 3.1 line folding rules.\r\n");
887 input.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
888 let parsed = parse_calendar(&input).unwrap();
889 assert_eq!(
890 parsed.events[0].summary,
891 "This is a very long event title that has been\
892 folded across multiple physical lines per RFC 5545\
893 section 3.1 line folding rules."
894 );
895 }
896
897 #[test]
898 fn parse_calendar_handles_tab_continuation_too() {
899 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
900 input.push_str("BEGIN:VEVENT\r\n");
901 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
902 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
903 input.push_str("SUMMARY:long\r\n\tvalue\r\n");
904 input.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
905 let parsed = parse_calendar(&input).unwrap();
906 assert_eq!(parsed.events[0].summary, "longvalue");
907 }
908
909 #[test]
910 fn parse_calendar_accepts_lf_only_line_terminators() {
911 let mut input = String::from("BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//mh//EN\n");
913 input.push_str("BEGIN:VEVENT\n");
914 input.push_str("UID:e1\nDTSTAMP:20260101T000000Z\n");
915 input.push_str("DTSTART;VALUE=DATE:20260429\nDTEND;VALUE=DATE:20260430\n");
916 input.push_str("SUMMARY:s\nEND:VEVENT\nEND:VCALENDAR\n");
917 let parsed = parse_calendar(&input).unwrap();
918 assert_eq!(parsed.events.len(), 1);
919 assert_eq!(parsed.events[0].summary, "s");
920 }
921
922 #[test]
923 fn parse_calendar_preserves_japanese_utf8_across_fold() {
924 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
930 input.push_str("BEGIN:VEVENT\r\n");
931 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
932 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
933 input.push_str("SUMMARY:憲法\r\n 記念日\r\n");
934 input.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
935 let parsed = parse_calendar(&input).unwrap();
936 assert_eq!(parsed.events[0].summary, "憲法記念日");
937 }
938
939 #[test]
942 fn invalid_dtstamp_error_message_carries_line_number() {
943 let mut input = String::from("BEGIN:VCALENDAR\r\n"); input.push_str("VERSION:2.0\r\n"); input.push_str("PRODID:-//mh//EN\r\n"); input.push_str("BEGIN:VEVENT\r\n"); input.push_str("UID:e1\r\n"); input.push_str("DTSTAMP:NOT-A-DATE\r\n"); input.push_str("DTSTART;VALUE=DATE:20260429\r\n");
951 input.push_str("DTEND;VALUE=DATE:20260430\r\n");
952 input.push_str("SUMMARY:s\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
953 let err = parse_calendar(&input).unwrap_err();
954 let msg = err.to_string();
955 assert!(
956 msg.contains("at line 6"),
957 "expected 'at line 6' in error: {msg}"
958 );
959 assert!(
960 msg.contains("DTSTAMP"),
961 "expected DTSTAMP property name in error: {msg}"
962 );
963 }
964
965 #[test]
966 fn missing_required_field_error_carries_end_vevent_line() {
967 let mut input = String::from("BEGIN:VCALENDAR\r\n"); input.push_str("VERSION:2.0\r\n"); input.push_str("PRODID:-//mh//EN\r\n"); input.push_str("BEGIN:VEVENT\r\n"); input.push_str("UID:e1\r\n"); input.push_str("DTSTART;VALUE=DATE:20260429\r\n"); input.push_str("DTEND;VALUE=DATE:20260430\r\n"); input.push_str("SUMMARY:s\r\n"); input.push_str("END:VEVENT\r\n"); input.push_str("END:VCALENDAR\r\n");
979 let err = parse_calendar(&input).unwrap_err();
980 let msg = err.to_string();
981 assert!(msg.contains("at line 9"), "expected 'at line 9': {msg}");
982 assert!(msg.contains("missing DTSTAMP"));
983 }
984
985 #[test]
986 fn dispatch_handles_property_with_extra_params() {
987 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
990 input.push_str("BEGIN:VEVENT\r\n");
991 input.push_str("UID;X-FOO=bar:event-uid-with-param\r\n");
992 input.push_str("DTSTAMP:20260101T000000Z\r\n");
993 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
994 input.push_str("SUMMARY:s\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
995 let parsed = parse_calendar(&input).unwrap();
996 assert_eq!(parsed.events[0].uid, "event-uid-with-param");
997 }
998
999 #[test]
1000 fn dispatch_handles_value_date_param_in_any_position() {
1001 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
1005 input.push_str("BEGIN:VEVENT\r\n");
1006 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
1007 input.push_str("DTSTART;TZID=Asia/Tokyo;VALUE=DATE:20260429\r\n");
1008 input.push_str("DTEND;VALUE=DATE:20260430\r\n");
1009 input.push_str("SUMMARY:s\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
1010 let parsed = parse_calendar(&input).unwrap();
1011 assert_eq!(
1012 parsed.events[0].dtstart,
1013 chrono::NaiveDate::from_ymd_opt(2026, 4, 29).unwrap()
1014 );
1015 }
1016
1017 #[test]
1020 fn summary_with_comma_round_trips_via_escape() {
1021 let mut event = make_event("rt-esc-comma", (2026, 4, 29), (2026, 4, 30), "");
1022 event.summary = "Lunch, dinner, snack".to_string();
1023 let cal = format_calendar(&vcal(vec![event.clone()]));
1024 assert!(cal.contains(r"SUMMARY:Lunch\, dinner\, snack"));
1026 let parsed = parse_calendar(&cal).unwrap();
1027 assert_eq!(parsed.events[0].summary, "Lunch, dinner, snack");
1029 }
1030
1031 #[test]
1032 fn summary_with_semicolon_round_trips_via_escape() {
1033 let mut event = make_event("rt-esc-semi", (2026, 4, 29), (2026, 4, 30), "");
1034 event.summary = "Q1; Q2".to_string();
1035 let cal = format_calendar(&vcal(vec![event.clone()]));
1036 assert!(cal.contains(r"SUMMARY:Q1\; Q2"));
1037 let parsed = parse_calendar(&cal).unwrap();
1038 assert_eq!(parsed.events[0].summary, "Q1; Q2");
1039 }
1040
1041 #[test]
1042 fn summary_with_newline_round_trips_via_escape() {
1043 let mut event = make_event("rt-esc-nl", (2026, 4, 29), (2026, 4, 30), "");
1044 event.summary = "Line1\nLine2".to_string();
1045 let cal = format_calendar(&vcal(vec![event.clone()]));
1046 assert!(cal.contains(r"SUMMARY:Line1\nLine2"));
1047 let parsed = parse_calendar(&cal).unwrap();
1048 assert_eq!(parsed.events[0].summary, "Line1\nLine2");
1049 }
1050
1051 #[test]
1052 fn summary_with_backslash_round_trips_via_escape() {
1053 let mut event = make_event("rt-esc-bs", (2026, 4, 29), (2026, 4, 30), "");
1054 event.summary = r"path\to\file".to_string();
1055 let cal = format_calendar(&vcal(vec![event.clone()]));
1056 assert!(cal.contains(r"SUMMARY:path\\to\\file"));
1057 let parsed = parse_calendar(&cal).unwrap();
1058 assert_eq!(parsed.events[0].summary, r"path\to\file");
1059 }
1060
1061 #[test]
1062 fn categories_with_commas_in_items_round_trip() {
1063 let mut event = make_event("rt-cat-comma", (2026, 4, 29), (2026, 4, 30), "x");
1065 event.categories = vec!["work, project A".to_string(), "personal".to_string()];
1066 let cal = format_calendar(&vcal(vec![event.clone()]));
1067 assert!(cal.contains(r"CATEGORIES:work\, project A,personal"));
1068 let parsed = parse_calendar(&cal).unwrap();
1069 assert_eq!(parsed.events[0].categories.len(), 2);
1070 assert_eq!(parsed.events[0].categories[0], "work, project A");
1071 assert_eq!(parsed.events[0].categories[1], "personal");
1072 }
1073
1074 #[test]
1075 fn raw_property_value_is_not_escape_decoded() {
1076 let mut input = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mh//EN\r\n");
1080 input.push_str("BEGIN:VEVENT\r\n");
1081 input.push_str("UID:e1\r\nDTSTAMP:20260101T000000Z\r\n");
1082 input.push_str("DTSTART;VALUE=DATE:20260429\r\nDTEND;VALUE=DATE:20260430\r\n");
1083 input.push_str("SUMMARY:s\r\n");
1084 input.push_str(r"X-CUSTOM-FOO:value with \,comma");
1085 input.push_str("\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
1086 let parsed = parse_calendar(&input).unwrap();
1087 let rp = &parsed.events[0].unknown[0];
1088 assert_eq!(rp.name, "X-CUSTOM-FOO");
1089 assert_eq!(rp.value, r"value with \,comma"); }
1091
1092 #[test]
1093 fn vtimezone_format_round_trip_yields_same_structure() {
1094 let cal = VCalendar {
1095 version: "2.0".to_string(),
1096 prodid: "-//mh//EN".to_string(),
1097 calscale: None,
1098 method: None,
1099 events: vec![],
1100 unrecognized_components: vec![RawComponent {
1101 name: "VTIMEZONE".to_string(),
1102 properties: vec![RawProperty {
1103 name: "TZID".to_string(),
1104 params: vec![],
1105 value: "Asia/Tokyo".to_string(),
1106 source_index: 1,
1107 }],
1108 sub_components: vec![],
1109 }],
1110 };
1111 let s = format_calendar(&cal);
1112 let reparsed = parse_calendar(&s).unwrap();
1113 assert_eq!(reparsed.unrecognized_components.len(), 1);
1114 assert_eq!(reparsed.unrecognized_components[0].name, "VTIMEZONE");
1115 assert_eq!(
1116 reparsed.unrecognized_components[0].properties[0].value,
1117 "Asia/Tokyo"
1118 );
1119 }
1120}