Skip to main content

ics_core/parser/
mod.rs

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
14/// Parse the full ICS document into a typed `VCalendar`.
15///
16/// The input flows through `unfold::unfold` first, which strips a leading
17/// UTF-8 BOM and joins RFC 5545 folded continuation lines into logical
18/// lines. Calendar-level non-`VEVENT` components (`VTIMEZONE`,
19/// `VJOURNAL`, etc.) are preserved into `VCalendar.unrecognized_components`.
20/// Non-typed nested components inside a `VEVENT` (e.g. `VALARM`) flow
21/// into `VEvent.unrecognized_components`.
22pub 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    // Skip until BEGIN:VCALENDAR. Be lenient about leading whitespace /
28    // comments / BOM that may have slipped through.
29    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; // step past BEGIN:VCALENDAR
36
37    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        // Otherwise it's a calendar-level property line.
62        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        // Calendar-level X-WR-*, unknown X-*, and other properties are
72        // not yet captured at this layer — landing alongside ADR-018
73        // round-trip work for the calendar shell.
74        idx += 1;
75    }
76
77    Ok(VCalendar {
78        version,
79        prodid,
80        calscale,
81        method,
82        events,
83        unrecognized_components,
84    })
85}
86
87/// Thin compatibility wrapper returning only the events.
88pub 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
100/// Parse a `VEVENT` body starting at `start` (immediately after
101/// `BEGIN:VEVENT`). Returns the parsed event and the index of the line
102/// immediately after `END:VEVENT`.
103fn 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    // Monotonic across all X-* properties in this VEVENT regardless of
118    // which bucket they land in — preserves source-arrival order for the
119    // per-bucket sort the formatter does (ADR-018).
120    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                    // Timed events (DTSTART;VALUE=DATE-TIME or no VALUE) currently
195                    // fall through silently per ADR-001 Rule 9; v0.3.0 lifts this.
196                }
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                    // Unknown non-X property — ignored for now. Future work:
234                    // promote to VEvent.unknown for full round-trip preservation.
235                }
236            }
237        }
238        idx += 1;
239    }
240    Err(Error::parse("VEVENT missing END:VEVENT"))
241}
242
243/// True if the parameter list contains `VALUE=DATE` (date-only typing).
244fn has_value_date_param(params: &[(String, String)]) -> bool {
245    params.iter().any(|(k, v)| k == "VALUE" && v == "DATE")
246}
247
248/// Recursively capture a `BEGIN:<name>...END:<name>` block as a
249/// `RawComponent`. Nested `BEGIN:`/`END:` blocks become entries in
250/// `sub_components`. Returns the component and the line index immediately
251/// after the matching `END:<name>`.
252fn 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            // Mismatched END (or END for an outer scope) — bail out;
272            // upstream component will consume it.
273            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    // Reached EOF before END — best-effort return.
295    (
296        RawComponent {
297            name,
298            properties,
299            sub_components,
300        },
301        idx,
302    )
303}
304
305/// Parse a property line `NAME[;PARAM=VALUE...]:VALUE` into a `RawProperty`.
306///
307/// Thin wrapper around `line::parse_logical_line` + `LogicalLine::to_raw_property`
308/// kept for the unrecognized-component path and for existing tests. Quoted
309/// parameter values have their surrounding `"` stripped; TEXT-value
310/// escapes are left intact per ADR-018 (raw value preservation).
311pub(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
315/// Parse index specifier: "3", "1,4,6", "3-7", "1,3-5,8"
316/// Returns sorted, deduplicated 1-based indices.
317pub 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        // make_event leaves microsoft = None and transp = None; the formatter
393        // omits both TRANSP and X-MICROSOFT-CDO-BUSYSTATUS in that case so
394        // the round-trip is exact (no inferred fields appearing in the
395        // re-parsed value).
396        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    // ADR-001 Migration Step 1 — unknown property round-trip.
447
448    #[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        // Post-Step-5: X-MAKEHOLIDAY-ICON is no longer specially handled
507        // in ics-core; it round-trips through VEvent.unknown like any
508        // other X-* property. Read/write is the makeholiday crate's job.
509        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    // ADR-001 Migration Step 2 — RawComponent + unrecognized_components.
569
570    #[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    // ADR-001 Migration Step 3 — TRANSP typed field.
621
622    #[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        // If transp is explicitly set, the formatter must honor it even
638        // when microsoft.busystatus would derive a different value.
639        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![], // derives OPAQUE
643        });
644        event.transp = Some(crate::Transp::Transparent); // typed override
645        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![], // derives OPAQUE
656        });
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    // ADR-001 Migration Step 6 — per-vendor unrecognized fallback.
680
681    #[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        // Microsoft prefix properties land in microsoft.unrecognized.
696        let ms = event.microsoft.as_ref().unwrap();
697        assert_eq!(ms.busystatus, None); // typed slot still empty
698        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        // Non-Microsoft X-* stays in VEvent.unknown.
706        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        // Input order A, MS, B should yield X-CUSTOM-A at index 1,
749        // X-MICROSOFT-FOO at index 2, X-CUSTOM-B at index 3.
750        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        // No X-MICROSOFT-* input means microsoft bundle is None entirely.
781        assert!(parsed.events[0].microsoft.is_none());
782    }
783
784    // ADR-001 Migration Step 7 — google / icloud skeleton routing.
785
786    #[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    // ADR-019 Step 0 — folding + BOM acceptance at the parse_calendar boundary.
861
862    #[test]
863    fn parse_calendar_accepts_leading_utf8_bom() {
864        // Outlook etc. emit a UTF-8 BOM. parse_calendar must tolerate it.
865        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        // A long SUMMARY split across multiple physical lines per RFC 5545 §3.1.
880        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        // Some tools emit Unix line endings. The unfolder accepts both.
912        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        // Multi-byte UTF-8 split across a fold boundary must reassemble
925        // correctly. The boundary lands between bytes, not between chars,
926        // but since the folding-marker whitespace is single-byte ASCII
927        // and we drop only that single byte, surrounding multi-byte
928        // sequences survive intact.
929        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    // ADR-019 Step 1 — LogicalLine dispatch + parse error line numbers.
940
941    #[test]
942    fn invalid_dtstamp_error_message_carries_line_number() {
943        // The bogus DTSTAMP is on logical line 6 (post-unfold, 1-based).
944        let mut input = String::from("BEGIN:VCALENDAR\r\n"); // line 1
945        input.push_str("VERSION:2.0\r\n"); // line 2
946        input.push_str("PRODID:-//mh//EN\r\n"); // line 3
947        input.push_str("BEGIN:VEVENT\r\n"); // line 4
948        input.push_str("UID:e1\r\n"); // line 5
949        input.push_str("DTSTAMP:NOT-A-DATE\r\n"); // line 6 — error here
950        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        // VEVENT body has no DTSTAMP; the END:VEVENT line is where we
968        // discover the missing required field.
969        let mut input = String::from("BEGIN:VCALENDAR\r\n"); // line 1
970        input.push_str("VERSION:2.0\r\n"); // line 2
971        input.push_str("PRODID:-//mh//EN\r\n"); // line 3
972        input.push_str("BEGIN:VEVENT\r\n"); // line 4
973        input.push_str("UID:e1\r\n"); // line 5
974        input.push_str("DTSTART;VALUE=DATE:20260429\r\n"); // line 6
975        input.push_str("DTEND;VALUE=DATE:20260430\r\n"); // line 7
976        input.push_str("SUMMARY:s\r\n"); // line 8
977        input.push_str("END:VEVENT\r\n"); // line 9 — END:VEVENT
978        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        // UID;X-FOO=bar:abc-123 must still set uid; the old strip_prefix
988        // dispatcher would have missed this because of the inline param.
989        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        // DTSTART;TZID=Asia/Tokyo;VALUE=DATE:20260429 — VALUE=DATE is the
1002        // second param, not the first. With LogicalLine the param scan is
1003        // order-independent.
1004        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    // ADR-019 Step 2 — TEXT escape decode/encode applied to typed fields.
1018
1019    #[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        // Wire form has escaped commas.
1025        assert!(cal.contains(r"SUMMARY:Lunch\, dinner\, snack"));
1026        let parsed = parse_calendar(&cal).unwrap();
1027        // Parsed summary has them decoded back.
1028        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        // An item with a literal comma must survive split + decode.
1064        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        // RawProperty.value stays raw per ADR-018 — escape interpretation
1077        // only applies to typed TEXT fields. An X- property's value
1078        // preserves the backslash.
1079        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"); // raw, not decoded
1090    }
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}