Skip to main content

ical/tree/
cst.rs

1//! # Concrete syntax tree
2//!
3//! The core representation: a whole calendar as generic, byte-faithful syntax.
4//!
5//! [`IcalCst`] is the hub of the crate. It models a component (the
6//! `VCALENDAR` and every nested `VEVENT`, `VALARM`, `VTIMEZONE`, ...) as a
7//! `BEGIN` / `END` envelope wrapping an ordered body of
8//! [`items`](IcalCst::items): property lines and nested components.
9//!
10//! They are kept in source order, so anything round-trips byte for byte even
11//! when a producer interleaves them. The tree itself knows nothing about what
12//! a property or component *means*.
13//!
14//! It is filled from bytes ([`parse`](IcalCst::parse)) or from typed items,
15//! and exports its bytes byte-faithfully ([`to_bytes`](IcalCst::to_bytes), or
16//! the lossy-for-non-UTF-8 [`Display`](core::fmt::Display)).
17//!
18//! Typed access goes by lens ([`prop`](IcalCst::prop),
19//! [`prop_mut`](IcalCst::prop_mut), [`component`](IcalCst::component)).
20//!
21//! The semantic projection ([`decode`](IcalCst::decode)) and the codec live
22//! in the [`decode`](crate::tree::codec::decode) /
23//! [`encode`](crate::tree::codec::encode) siblings.
24//!
25//! # Examples
26//!
27//! ```rust
28//! use ical::tree::cst::IcalCst;
29//! use ical::component::vevent::VEVENT;
30//! use ical::prop::summary::SUMMARY;
31//!
32//! let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\nBEGIN:VEVENT\r\nUID:1\r\nDTSTAMP:20260101T000000Z\r\nSUMMARY:Lunch\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
33//! let mut cst = IcalCst::parse(raw).unwrap();
34//! assert_eq!(cst.to_string(), raw);
35//!
36//! cst.component_mut::<VEVENT>()
37//!     .unwrap()
38//!     .prop_mut::<SUMMARY>()
39//!     .unwrap()
40//!     .set_text("Dinner");
41//! assert!(cst.to_string().contains("SUMMARY:Dinner\r\n"));
42//! ```
43
44use core::{fmt, iter, mem};
45
46use alloc::{
47    borrow::Cow,
48    boxed::Box,
49    string::{String, ToString},
50    vec,
51    vec::Vec,
52};
53
54use crate::{
55    component::spec::IcalComponentSpec,
56    prop::IcalProp,
57    tree::{codec::mode::Escaper, error::IcalParseError, line::IcalLine, prop::lens::IcalPropLens},
58    version::IcalVersion,
59};
60
61/// One item in a component body: a property line, or a nested component.
62#[derive(Clone, Debug)]
63pub enum IcalItem<'a> {
64    /// A property line.
65    Prop(IcalLine<'a>),
66    /// A nested component (its own `BEGIN` / `END` subtree). Boxed, since a
67    /// component is recursive and a property line is not.
68    Component(Box<IcalCst<'a>>),
69    /// One physical line that could not be structured, kept verbatim (its
70    /// ending included) so it still round-trips. Only
71    /// [`parse_recovering`](IcalCst::parse_recovering) ever produces one: the
72    /// strict entry points refuse the calendar instead.
73    Opaque(Cow<'a, [u8]>),
74}
75
76/// A component as raw syntax: an optional envelope and its ordered body.
77///
78/// The `BEGIN` / `END` envelope wraps property lines and nested components, in
79/// source order. Used for the `VCALENDAR` root and every subcomponent alike;
80/// it is absent only for a bare record parsed by [`parse`](IcalCst::parse).
81#[derive(Clone, Debug)]
82pub struct IcalCst<'a> {
83    /// The `BEGIN` line, or `None` for a bare record parsed without an
84    /// envelope.
85    pub begin: Option<IcalLine<'a>>,
86    /// The body items (property lines and nested components), in source order.
87    pub items: Vec<IcalItem<'a>>,
88    /// The `END` line, absent exactly when [`begin`](Self::begin) is.
89    pub end: Option<IcalLine<'a>>,
90    /// The blank lines after `END`, kept so a file ending in one round-trips.
91    /// Only ever set on a root calendar, and only when nothing but whitespace
92    /// follows it.
93    pub trailing: Cow<'a, str>,
94}
95
96impl<'a> IcalCst<'a> {
97    /// Start an empty iCalendar 2.0 calendar, BEGIN/VERSION/END seeded, ready
98    /// for properties and components.
99    pub fn v2() -> Self {
100        Self {
101            begin: Some(IcalLine::text("BEGIN", "VCALENDAR")),
102            items: vec![IcalItem::Prop(IcalLine::text(
103                "VERSION",
104                &*IcalVersion::V2_0,
105            ))],
106            end: Some(IcalLine::text("END", "VCALENDAR")),
107            trailing: Cow::Borrowed(""),
108        }
109    }
110
111    /// Parse the first calendar from raw text, borrowed for the Cst lifetime.
112    ///
113    /// A bare, envelope-less record (every line a property) is also accepted,
114    /// so a lone component fragment round-trips. Anything after the first
115    /// calendar is dropped, except trailing blank lines, which are kept: use
116    /// [`parse_many`](Self::parse_many) to read a multi-calendar file whole.
117    pub fn parse<T: AsRef<[u8]> + ?Sized>(input: &'a T) -> Result<Self, IcalParseError> {
118        let input = input.as_ref();
119        let (first, _rest) = IcalLine::take(input)?;
120
121        if first.name.get().eq_ignore_ascii_case("BEGIN") {
122            let (mut cst, rest) = Self::take_component(input)?;
123            cst.take_trailing(rest);
124            let escaper = Escaper::for_version_str(&cst.version_str());
125            cst.stamp_escaper(escaper);
126            Ok(cst)
127        } else {
128            Self::parse_bare(input)
129        }
130    }
131
132    /// Parse a bare, envelope-less record: every line becomes a property.
133    fn parse_bare(input: &'a [u8]) -> Result<Self, IcalParseError> {
134        let mut items: Vec<IcalItem<'a>> = Vec::new();
135        let mut rest = input;
136
137        while !is_blank(rest) {
138            let (line, tail) = IcalLine::take(rest)?;
139            items.push(IcalItem::Prop(line));
140            rest = tail;
141        }
142
143        let mut cst = Self {
144            begin: None,
145            items,
146            end: None,
147            trailing: Cow::Borrowed(""),
148        };
149        cst.take_trailing(rest);
150        let escaper = Escaper::for_version_str(&cst.version_str());
151        cst.stamp_escaper(escaper);
152        Ok(cst)
153    }
154
155    /// Parse every top-level calendar in the input, lazily, one item each.
156    ///
157    /// An item is a calendar or the parse error that stopped iteration. Blank
158    /// lines between calendars belong to the calendar that follows them, and
159    /// those after the last one to the last calendar, so concatenating what
160    /// this yields reproduces the file byte for byte.
161    pub fn parse_many<T: AsRef<[u8]> + ?Sized>(
162        input: &'a T,
163    ) -> impl Iterator<Item = Result<Self, IcalParseError>> {
164        let mut rest = input.as_ref();
165
166        iter::from_fn(move || {
167            if is_blank(rest) {
168                return None;
169            }
170
171            match Self::take_component(rest) {
172                Ok((mut cst, tail)) => {
173                    rest = cst.take_trailing(tail);
174                    let escaper = Escaper::for_version_str(&cst.version_str());
175                    cst.stamp_escaper(escaper);
176                    Some(Ok(cst))
177                }
178                Err(error) => {
179                    rest = b"";
180                    Some(Err(error))
181                }
182            }
183        })
184    }
185
186    /// Parse the whole input, recovering from anything that cannot be
187    /// structured instead of refusing the calendar.
188    ///
189    /// A physical line with no colon, or whose name is not UTF-8, is kept as
190    /// an [`Opaque`](IcalItem::Opaque) item and parsing carries on, and a
191    /// component left open at end of input is closed with no `END`. The bytes
192    /// survive either way, so the recovered calendars serialize back unchanged.
193    ///
194    /// Every problem is reported in [`IcalRecovery::problems`]. The strict
195    /// [`parse`](Self::parse) and [`parse_many`](Self::parse_many) stay the
196    /// default: use this when a calendar from the wild matters more than the
197    /// guarantee it was well formed.
198    pub fn parse_recovering<T: AsRef<[u8]> + ?Sized>(input: &'a T) -> IcalRecovery<'a> {
199        let mut rest = input.as_ref();
200        let mut recovery = IcalRecovery::default();
201
202        // NOTE: Items outside any BEGIN, which is where a bare record's
203        // properties and any stray line land.
204        let mut loose: Vec<IcalItem<'a>> = Vec::new();
205
206        while !is_blank(rest) {
207            match IcalLine::take(rest) {
208                Ok((line, _tail)) if line.name.get().eq_ignore_ascii_case("BEGIN") => {
209                    recovery.close_loose(&mut loose);
210
211                    let (mut cst, tail) = Self::take_component_recovering(rest, &mut recovery);
212                    rest = tail;
213                    let escaper = Escaper::for_version_str(&cst.version_str());
214                    cst.stamp_escaper(escaper);
215                    recovery.calendars.push(cst);
216                }
217                Ok((line, tail)) => {
218                    loose.push(IcalItem::Prop(line));
219                    rest = tail;
220                }
221                Err(error) => {
222                    let (opaque, tail) = IcalLine::take_physical(rest);
223                    loose.push(IcalItem::Opaque(Cow::Borrowed(opaque)));
224                    recovery.problems.push(error);
225                    rest = tail;
226                }
227            }
228        }
229
230        recovery.close_loose(&mut loose);
231
232        if let Some(last) = recovery.calendars.last_mut() {
233            last.take_trailing(rest);
234        } else {
235            let mut bare = Self::bare(Vec::new());
236            bare.take_trailing(rest);
237            recovery.calendars.push(bare);
238        }
239
240        recovery
241    }
242
243    /// Take one component recovering from what it cannot structure: an
244    /// unstructurable line becomes an opaque item, and an unclosed component is
245    /// closed at end of input.
246    fn take_component_recovering(
247        input: &'a [u8],
248        recovery: &mut IcalRecovery<'a>,
249    ) -> (Self, &'a [u8]) {
250        // NOTE: The caller only ever enters here on a line that tokenised as
251        // BEGIN.
252        let (begin, mut rest) = IcalLine::take(input).expect("a BEGIN line");
253        let name = begin.raw_value_str().into_owned();
254
255        let mut items: Vec<IcalItem<'a>> = Vec::new();
256
257        loop {
258            if is_blank(rest) {
259                recovery.problems.push(IcalParseError::MissingEnd(name));
260                return (
261                    Self {
262                        begin: Some(begin),
263                        items,
264                        end: None,
265                        trailing: Cow::Borrowed(""),
266                    },
267                    rest,
268                );
269            }
270
271            match IcalLine::take(rest) {
272                Ok((line, tail)) => {
273                    let line_name = line.name.get();
274
275                    if line_name.eq_ignore_ascii_case("END") {
276                        return (
277                            Self {
278                                begin: Some(begin),
279                                items,
280                                end: Some(line),
281                                trailing: Cow::Borrowed(""),
282                            },
283                            tail,
284                        );
285                    }
286
287                    if line_name.eq_ignore_ascii_case("BEGIN") {
288                        let (child, next) = Self::take_component_recovering(rest, recovery);
289                        items.push(IcalItem::Component(Box::new(child)));
290                        rest = next;
291                        continue;
292                    }
293
294                    items.push(IcalItem::Prop(line));
295                    rest = tail;
296                }
297                Err(error) => {
298                    let (opaque, tail) = IcalLine::take_physical(rest);
299                    items.push(IcalItem::Opaque(Cow::Borrowed(opaque)));
300                    recovery.problems.push(error);
301                    rest = tail;
302                }
303            }
304        }
305    }
306
307    /// A bare, envelope-less calendar around `items`.
308    fn bare(items: Vec<IcalItem<'a>>) -> Self {
309        Self {
310            begin: None,
311            items,
312            end: None,
313            trailing: Cow::Borrowed(""),
314        }
315    }
316
317    /// Keep `rest` as this calendar's trailing blank lines when nothing but
318    /// whitespace follows, and report what is left to parse.
319    fn take_trailing(&mut self, rest: &'a [u8]) -> &'a [u8] {
320        if !is_blank(rest) {
321            return rest;
322        }
323
324        self.trailing = Cow::Borrowed(str::from_utf8(rest).unwrap_or(""));
325        b""
326    }
327
328    /// Take one component (recursively) off the front of `input`, returning it
329    /// and the unconsumed rest. A nested `BEGIN` recurses; the matching `END`
330    /// closes this component.
331    fn take_component(input: &'a [u8]) -> Result<(Self, &'a [u8]), IcalParseError> {
332        let (begin, mut rest) = IcalLine::take(input)?;
333
334        if !begin.name.get().eq_ignore_ascii_case("BEGIN") {
335            return Err(IcalParseError::ExpectedBegin(begin.name.get().to_string()));
336        }
337
338        let mut items: Vec<IcalItem<'a>> = Vec::new();
339
340        loop {
341            if rest.is_empty() {
342                // NOTE: The component's name, not the whole input: an error
343                // that carries a megabyte of calendar is not a diagnostic.
344                return Err(IcalParseError::MissingEnd(
345                    begin.raw_value_str().into_owned(),
346                ));
347            }
348
349            let (line, tail) = IcalLine::take(rest)?;
350            let name = line.name.get();
351
352            if name.eq_ignore_ascii_case("END") {
353                return Ok((
354                    Self {
355                        begin: Some(begin),
356                        items,
357                        end: Some(line),
358                        trailing: Cow::Borrowed(""),
359                    },
360                    tail,
361                ));
362            }
363
364            if name.eq_ignore_ascii_case("BEGIN") {
365                let (child, next) = Self::take_component(rest)?;
366                items.push(IcalItem::Component(Box::new(child)));
367                rest = next;
368                continue;
369            }
370
371            items.push(IcalItem::Prop(line));
372            rest = tail;
373        }
374    }
375
376    /// Stamp the escaping mode onto every value and parameter node in the
377    /// subtree, once the root `VERSION` is known (it can only be determined for
378    /// the whole tree).
379    fn stamp_escaper(&mut self, escaper: Escaper) {
380        for item in &mut self.items {
381            match item {
382                IcalItem::Prop(line) => {
383                    line.value.escaper = escaper;
384                    for param in &mut line.params {
385                        param.escaper = escaper;
386                    }
387                }
388                IcalItem::Component(child) => child.stamp_escaper(escaper),
389                IcalItem::Opaque(_) => {}
390            }
391        }
392    }
393
394    /// The `VERSION` line among this component's direct properties, if any.
395    fn version_str(&self) -> Cow<'_, str> {
396        self.items
397            .iter()
398            .find_map(|item| match item {
399                IcalItem::Prop(line) if line.name.get().eq_ignore_ascii_case("VERSION") => {
400                    Some(line.raw_value_str())
401                }
402                _ => None,
403            })
404            .unwrap_or(Cow::Borrowed(""))
405    }
406
407    /// The calendar's version indicator, read from its `VERSION` line. An
408    /// unrecognised or missing version normalises to
409    /// [`V2_0`](IcalVersion::V2_0).
410    pub fn version(&self) -> IcalVersion {
411        self.version_str().parse().unwrap_or(IcalVersion::V2_0)
412    }
413
414    /// Append a typed property to this component, encoding it into a line.
415    pub fn push(&mut self, prop: IcalProp<'a>) -> &mut Self {
416        let escaper = Escaper::for_version_str(&self.version_str());
417        self.items.push(IcalItem::Prop(prop.encode(escaper)));
418        self
419    }
420
421    /// Append a nested component to this one.
422    pub fn push_component(&mut self, component: IcalCst<'a>) -> &mut Self {
423        self.items.push(IcalItem::Component(Box::new(component)));
424        self
425    }
426
427    /// Remove every property of type `L` from this component's direct
428    /// properties.
429    pub fn remove<L: IcalPropLens>(&mut self) -> &mut Self {
430        self.items.retain(|item| match item {
431            IcalItem::Prop(line) => !line.name.get().eq_ignore_ascii_case(&L::KIND),
432            IcalItem::Component(_) => true,
433            IcalItem::Opaque(_) => true,
434        });
435        self
436    }
437
438    /// The first direct property of type `L`, decoded into a borrowed snapshot.
439    pub fn prop<L: IcalPropLens>(&self) -> Option<L::Target<'_>> {
440        let version = self.version();
441        self.items.iter().find_map(|item| match item {
442            IcalItem::Prop(line) if line.name.get().eq_ignore_ascii_case(&L::KIND) => {
443                Some(L::decode(line, version))
444            }
445            _ => None,
446        })
447    }
448
449    /// The first direct property of type `L`, as a typed cursor for in-place
450    /// editing.
451    pub fn prop_mut<L: IcalPropLens>(&mut self) -> Option<L::Cursor<'_, 'a>> {
452        self.items.iter_mut().find_map(|item| match item {
453            IcalItem::Prop(line) if line.name.get().eq_ignore_ascii_case(&L::KIND) => {
454                Some(L::cursor(line))
455            }
456            _ => None,
457        })
458    }
459
460    /// The first direct child component of type `C`, as a borrowed subtree.
461    pub fn component<C: IcalComponentSpec>(&self) -> Option<&IcalCst<'a>> {
462        self.items.iter().find_map(|item| match item {
463            IcalItem::Component(child) if child.is_kind::<C>() => Some(&**child),
464            _ => None,
465        })
466    }
467
468    /// The first direct child component of type `C`, mutably.
469    pub fn component_mut<C: IcalComponentSpec>(&mut self) -> Option<&mut IcalCst<'a>> {
470        self.items.iter_mut().find_map(|item| match item {
471            IcalItem::Component(child) if child.is_kind::<C>() => Some(&mut **child),
472            _ => None,
473        })
474    }
475
476    /// Every direct child component of type `C`, in source order.
477    pub fn components<C: IcalComponentSpec>(&self) -> impl Iterator<Item = &IcalCst<'a>> {
478        self.items.iter().filter_map(|item| match item {
479            IcalItem::Component(child) if child.is_kind::<C>() => Some(&**child),
480            _ => None,
481        })
482    }
483
484    /// Whether this component's `BEGIN` name matches the component marker `C`.
485    fn is_kind<C: IcalComponentSpec>(&self) -> bool {
486        self.begin
487            .as_ref()
488            .map(|begin| begin.raw_value_str().eq_ignore_ascii_case(&C::KIND))
489            .unwrap_or(false)
490    }
491
492    /// The wire name of this component (its `BEGIN` value), or `""` for a bare
493    /// record.
494    pub(crate) fn component_name(&self) -> Cow<'_, str> {
495        self.begin
496            .as_ref()
497            .map(|begin| begin.raw_value_str())
498            .unwrap_or(Cow::Borrowed(""))
499    }
500
501    /// Own every borrowed leaf, detaching the calendar from the source bytes so
502    /// it can outlive them.
503    pub fn into_static(self) -> IcalCst<'static> {
504        IcalCst {
505            begin: self.begin.map(IcalLine::into_static),
506            items: self
507                .items
508                .into_iter()
509                .map(|item| match item {
510                    IcalItem::Prop(line) => IcalItem::Prop(line.into_static()),
511                    IcalItem::Component(child) => {
512                        IcalItem::Component(Box::new(child.into_static()))
513                    }
514                    IcalItem::Opaque(bytes) => IcalItem::Opaque(Cow::Owned(bytes.into_owned())),
515                })
516                .collect(),
517            end: self.end.map(IcalLine::into_static),
518            trailing: Cow::Owned(self.trailing.into_owned()),
519        }
520    }
521
522    /// Serialize the calendar to raw bytes, exactly as parsed.
523    pub fn to_bytes(&self) -> Vec<u8> {
524        let mut out = Vec::new();
525        self.write_bytes(&mut out);
526        out
527    }
528
529    fn write_bytes(&self, out: &mut Vec<u8>) {
530        if let Some(begin) = &self.begin {
531            begin.write_bytes(out);
532        }
533        for item in &self.items {
534            match item {
535                IcalItem::Prop(line) => line.write_bytes(out),
536                IcalItem::Opaque(bytes) => out.extend_from_slice(bytes),
537                IcalItem::Component(child) => child.write_bytes(out),
538            }
539        }
540        if let Some(end) = &self.end {
541            end.write_bytes(out);
542        }
543        out.extend_from_slice(self.trailing.as_bytes());
544    }
545}
546
547/// What a recovering parse read: the calendars it could structure, and every
548/// problem it worked around.
549///
550/// The bytes are never lost, whatever the problems: serializing the calendars
551/// in order reproduces the input.
552#[derive(Clone, Debug, Default)]
553pub struct IcalRecovery<'a> {
554    /// Every top-level calendar, in source order. A run of lines outside any
555    /// `BEGIN` becomes a bare, envelope-less calendar of its own.
556    pub calendars: Vec<IcalCst<'a>>,
557    /// What could not be structured, in the order it was met.
558    pub problems: Vec<IcalParseError>,
559}
560
561impl<'a> IcalRecovery<'a> {
562    /// Whether the input parsed with nothing to work around, in which case a
563    /// strict parse would have accepted it too.
564    pub fn is_clean(&self) -> bool {
565        self.problems.is_empty()
566    }
567
568    /// Serialize every calendar, in order: the input, byte for byte.
569    pub fn to_bytes(&self) -> Vec<u8> {
570        let mut out = Vec::new();
571
572        for cst in &self.calendars {
573            cst.write_bytes(&mut out);
574        }
575
576        out
577    }
578
579    /// Close a run of loose items into a bare calendar, if there is one.
580    fn close_loose(&mut self, loose: &mut Vec<IcalItem<'a>>) {
581        if loose.is_empty() {
582            return;
583        }
584
585        self.calendars.push(IcalCst::bare(mem::take(loose)));
586    }
587}
588
589/// Whether nothing but blank-line bytes (`\r` / `\n`) is left.
590fn is_blank(bytes: &[u8]) -> bool {
591    bytes.iter().all(|byte| matches!(byte, b'\r' | b'\n'))
592}
593
594impl fmt::Display for IcalCst<'_> {
595    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
596        if let Some(begin) = &self.begin {
597            write!(f, "{begin}")?;
598        }
599        for item in &self.items {
600            match item {
601                IcalItem::Prop(line) => write!(f, "{line}")?,
602                IcalItem::Opaque(bytes) => f.write_str(&String::from_utf8_lossy(bytes))?,
603                IcalItem::Component(child) => write!(f, "{child}")?,
604            }
605        }
606        if let Some(end) = &self.end {
607            write!(f, "{end}")?;
608        }
609        Ok(())
610    }
611}
612
613#[cfg(test)]
614mod tests {
615    use alloc::{
616        string::{String, ToString},
617        vec::Vec,
618    };
619
620    use crate::{
621        component::vevent::VEVENT,
622        prop::{prodid::PRODID, summary::SUMMARY},
623        tree::{cst::IcalCst, error::IcalParseError},
624        version::IcalVersion,
625    };
626
627    const CAL: &str = concat!(
628        "BEGIN:VCALENDAR\r\n",
629        "VERSION:2.0\r\n",
630        "PRODID:-//Example//EN\r\n",
631        "BEGIN:VEVENT\r\n",
632        "UID:1\r\n",
633        "DTSTAMP:20260101T000000Z\r\n",
634        "SUMMARY:Lunch\r\n",
635        "BEGIN:VALARM\r\n",
636        "ACTION:DISPLAY\r\n",
637        "TRIGGER:-PT15M\r\n",
638        "END:VALARM\r\n",
639        "END:VEVENT\r\n",
640        "END:VCALENDAR\r\n",
641    );
642
643    #[test]
644    fn round_trips_a_nested_calendar_byte_for_byte() {
645        let cst = IcalCst::parse(CAL).unwrap();
646        assert_eq!(cst.to_string(), CAL);
647    }
648
649    #[test]
650    fn reads_a_nested_property_through_component_and_prop_lenses() {
651        let cst = IcalCst::parse(CAL).unwrap();
652        let event = cst.component::<VEVENT>().expect("a VEVENT");
653        assert_eq!(&*event.prop::<SUMMARY>().unwrap().0, "Lunch");
654    }
655
656    #[test]
657    fn edits_a_nested_property_leaving_every_other_byte_intact() {
658        let mut cst = IcalCst::parse(CAL).unwrap();
659        cst.component_mut::<VEVENT>()
660            .unwrap()
661            .prop_mut::<SUMMARY>()
662            .unwrap()
663            .set_text("Dinner");
664        assert_eq!(
665            cst.to_string(),
666            CAL.replace("SUMMARY:Lunch", "SUMMARY:Dinner")
667        );
668    }
669
670    #[test]
671    fn reports_the_version() {
672        let cst = IcalCst::parse(CAL).unwrap();
673        assert_eq!(cst.version(), IcalVersion::V2_0);
674    }
675
676    #[test]
677    fn round_trips_a_folded_calendar_byte_for_byte() {
678        // NOTE: What a real exporter emits: folded at a column, blank lines
679        // between components, and a blank line at the end of the file.
680        let raw = concat!(
681            "BEGIN:VCALENDAR\r\n",
682            "VERSION:2.0\r\n",
683            "PRODID:-//Example//EN\r\n",
684            "\r\n",
685            "BEGIN:VEVENT\r\n",
686            "UID:1\r\n",
687            "DTSTAMP:20260101T000000Z\r\n",
688            "DESCRIPTION:a very long description that an exporter would fold at s\r\n",
689            " ome column\r\n",
690            "END:VEVENT\r\n",
691            "END:VCALENDAR\r\n",
692            "\r\n",
693        );
694
695        let cst = IcalCst::parse(raw).unwrap();
696        assert_eq!(String::from_utf8(cst.to_bytes()).unwrap(), raw);
697    }
698
699    #[test]
700    fn round_trips_a_leading_blank_line() {
701        let raw = "\r\nBEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n";
702        let cst = IcalCst::parse(raw).unwrap();
703        assert_eq!(String::from_utf8(cst.to_bytes()).unwrap(), raw);
704    }
705
706    #[test]
707    fn round_trips_a_quoted_printable_value_ending_on_two_equals() {
708        // NOTE: The tokeniser records the soft break past the last logical
709        // byte and the splitter the dangling `=` before it, so a wire shape
710        // ordered by list rather than by offset emits `x=\r\n=` and the
711        // reparse swallows the END line.
712        let raw = concat!(
713            "BEGIN:VCALENDAR\r\n",
714            "VERSION:2.0\r\n",
715            "NOTE;ENCODING=QUOTED-PRINTABLE:x==\r\n",
716            "\r\n",
717            "END:VCALENDAR\r\n",
718        );
719
720        let cst = IcalCst::parse(raw).unwrap();
721        let bytes = cst.to_bytes();
722
723        assert_eq!(String::from_utf8(bytes.clone()).unwrap(), raw);
724        assert_eq!(IcalCst::parse(&bytes).unwrap().to_bytes(), bytes);
725    }
726
727    #[test]
728    fn round_trips_a_whole_multi_calendar_file() {
729        // NOTE: `parse` reads the first calendar and stops, so a file holding
730        // several round-trips through `parse_many`, whose output concatenates
731        // to the input, blank lines between calendars included.
732        let raw = concat!(
733            "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n",
734            "\r\n",
735            "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n",
736        );
737
738        let mut out = Vec::new();
739        for cst in IcalCst::parse_many(raw) {
740            out.extend_from_slice(&cst.unwrap().to_bytes());
741        }
742
743        assert_eq!(String::from_utf8(out).unwrap(), raw);
744    }
745
746    #[test]
747    fn recovers_a_line_with_no_colon() {
748        let raw = concat!(
749            "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n",
750            "this line has no colon\r\n",
751            "PRODID:-//Example//EN\r\nEND:VCALENDAR\r\n",
752        );
753
754        assert!(IcalCst::parse(raw).is_err());
755
756        let recovery = IcalCst::parse_recovering(raw);
757        assert_eq!(String::from_utf8(recovery.to_bytes()).unwrap(), raw);
758        assert_eq!(recovery.calendars.len(), 1);
759        assert!(matches!(
760            recovery.problems.as_slice(),
761            [IcalParseError::MissingPropertyColon(_)]
762        ));
763
764        let cal = &recovery.calendars[0];
765        assert_eq!(&*cal.prop::<PRODID>().unwrap().0, "-//Example//EN");
766    }
767
768    #[test]
769    fn recovers_a_component_with_no_end() {
770        let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:1\r\n";
771
772        assert!(IcalCst::parse(raw).is_err());
773
774        let recovery = IcalCst::parse_recovering(raw);
775        assert_eq!(String::from_utf8(recovery.to_bytes()).unwrap(), raw);
776        assert_eq!(
777            recovery.problems,
778            [
779                IcalParseError::MissingEnd("VEVENT".into()),
780                IcalParseError::MissingEnd("VCALENDAR".into()),
781            ]
782        );
783        assert!(recovery.calendars[0].component::<VEVENT>().is_some());
784    }
785
786    #[test]
787    fn reports_nothing_for_a_calendar_the_strict_parser_accepts() {
788        let recovery = IcalCst::parse_recovering(CAL);
789        assert!(recovery.is_clean());
790        assert_eq!(String::from_utf8(recovery.to_bytes()).unwrap(), CAL);
791    }
792
793    #[test]
794    fn refolds_nothing_once_a_value_is_edited() {
795        let raw = concat!(
796            "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n",
797            "SUMMARY:a summary long enough to have been fol\r\n ded by its exporter\r\n",
798            "END:VEVENT\r\nEND:VCALENDAR\r\n",
799        );
800
801        let mut cst = IcalCst::parse(raw).unwrap();
802        cst.component_mut::<VEVENT>()
803            .unwrap()
804            .prop_mut::<SUMMARY>()
805            .unwrap()
806            .set_text("Dinner");
807
808        assert_eq!(
809            String::from_utf8(cst.to_bytes()).unwrap(),
810            concat!(
811                "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n",
812                "SUMMARY:Dinner\r\n",
813                "END:VEVENT\r\nEND:VCALENDAR\r\n",
814            )
815        );
816    }
817}