Skip to main content

ical/tree/
line.rs

1//! # Content line
2//!
3//! One raw content line of a calendar: name, parameters, value, line ending.
4//!
5//! [`IcalLine`] is the syntactic unit a property occupies. It owns the line
6//! tokeniser ([`take`](IcalLine::take), which splits one logical line off the
7//! remaining input) and the head splitter separating the name from its
8//! parameters.
9//!
10//! It stays generic: what the name means and how the value decodes belong to
11//! the property lenses and the [`codec`](crate::tree::codec).
12//!
13//! Folding, stray blank lines and QUOTED-PRINTABLE soft breaks are resolved
14//! on parse, so every layer above sees one logical line, and recorded on the
15//! line's [`wire`](IcalLine::wire) shape, so serialization puts them back.
16//!
17//! A calendar therefore round-trips byte for byte however it was laid out.
18//! The final line needs no trailing break.
19
20use core::{fmt, str};
21
22use alloc::{borrow::Cow, string::String, vec, vec::Vec};
23
24use crate::tree::{
25    codec::mode::Escaper,
26    error::IcalParseError,
27    leaf::{IcalLeaf, IcalValueLeaf},
28    param::{lens::IcalParamLens, node::IcalParamNode},
29    value::node::IcalValueNode,
30    wire::IcalWire,
31};
32
33/// One raw content line: a name, parameters, a value and the line ending.
34///
35/// This is a *logical* line: [`take`](Self::take) unfolds RFC 5545 3.1
36/// continuations and QUOTED-PRINTABLE soft breaks, so a line holds no internal
37/// break, only its terminating [`eol`](Self::eol), and what it unfolded is
38/// kept on [`wire`](Self::wire) to put the folds back on output.
39///
40/// It is also the syntactic unit for the `BEGIN` / `VERSION` / `END` envelope
41/// lines, not only decoded properties.
42#[derive(Clone, Debug)]
43pub struct IcalLine<'a> {
44    /// The property name leaf, with any group prefix.
45    pub name: IcalLeaf<'a>,
46    /// The parameters, in source order.
47    pub params: Vec<IcalParamNode<'a>>,
48    /// The value.
49    pub value: IcalValueNode<'a>,
50    /// The line ending (`\r\n` or `\n`).
51    pub eol: IcalLeaf<'a>,
52    /// How the line was laid out on the wire: its folds, the blank lines before
53    /// it, its soft breaks. Empty for a built line, and dropped on output once
54    /// an edit changes the line's length (see [`IcalWire`]).
55    pub wire: IcalWire<'a>,
56}
57
58impl<'a> IcalLine<'a> {
59    /// Build a property line with a raw text value and the default `\r\n`
60    /// ending. Used to seed BEGIN/VERSION/END and to encode simple values.
61    pub fn text(name: impl Into<Cow<'a, str>>, value: impl Into<Cow<'a, str>>) -> Self {
62        Self {
63            name: IcalLeaf(name.into()),
64            params: Vec::new(),
65            value: IcalValueNode::from_components(
66                vec![vec![IcalValueLeaf::from(value.into())]],
67                Escaper::Modern,
68            ),
69            eol: IcalLeaf(Cow::Borrowed("\r\n")),
70            wire: IcalWire::default(),
71        }
72    }
73
74    /// Tokenise the logical line at the start of `rest`, unfolding as it goes.
75    ///
76    /// Returns the line and the remaining input. RFC 5545 3.1 folds a long line
77    /// by inserting a CRLF and a single leading space or tab; unfolding drops
78    /// them. A line with no folds borrows the source; a folded one is rebuilt
79    /// owned, its bytes no longer contiguous.
80    pub fn take(rest: &'a [u8]) -> Result<(Self, &'a [u8]), IcalParseError> {
81        // NOTE: Everything this tokeniser resolves is recorded here, so
82        // serialization can put it back.
83        let mut wire = IcalWire::default();
84
85        // NOTE: skip blank lines: real-world exports sometimes emit them.
86        let mut head = rest;
87        let (first, eol, mut tail) = loop {
88            if head.is_empty() {
89                return Err(IcalParseError::MissingCrlf(lossy(rest)));
90            }
91            let (content, eol, next) = physical_line(head);
92            if content.is_empty() {
93                head = next;
94                continue;
95            }
96            break (content, eol, next);
97        };
98
99        if head.len() < rest.len() {
100            wire.skipped(0, ascii(&rest[..rest.len() - head.len()]));
101        }
102
103        // NOTE: A line that begins with folding whitespace but has no line to
104        // continue (a dangling continuation, e.g. left after a dropped blank
105        // line) would fold into the previous line on reparse; strip the leading
106        // whitespace so it stays its own line, and record it so it still
107        // round-trips.
108        let indented = first;
109        let first = strip_leading_wsp(first);
110
111        if first.len() < indented.len() {
112            wire.skipped(0, ascii(&indented[..indented.len() - first.len()]));
113        }
114
115        // NOTE: QUOTED-PRINTABLE soft line breaks: a line whose head declares
116        // ENCODING=QUOTED-PRINTABLE and whose value ends with `=` continues on
117        // the next physical line. Param-driven, so it applies to any version's
118        // calendar that uses the encoding, not just 2.1.
119        if first.ends_with(b"=") && head_is_quoted_printable(first) {
120            let mut logical = Vec::from(&first[..first.len() - 1]);
121            wire.soft(logical.len(), is_crlf(eol));
122
123            let mut last_eol;
124            loop {
125                let (continuation, eol, next) = physical_line(tail);
126                last_eol = eol;
127                tail = next;
128                match continuation.strip_suffix(b"=") {
129                    Some(head) => {
130                        logical.extend_from_slice(head);
131                        if tail.is_empty() {
132                            // NOTE: The last continuation ends with a
133                            // soft-break marker and nothing follows: the `=` is
134                            // on the wire, the break after it is the line's own
135                            // ending.
136                            wire.skipped(logical.len(), "=");
137                            break;
138                        }
139                        wire.soft(logical.len(), is_crlf(eol));
140                    }
141                    None => {
142                        logical.extend_from_slice(continuation);
143                        break;
144                    }
145                }
146            }
147
148            let mut line = Self::parse(&logical, b"")?.into_static();
149            line.eol = eol_leaf(last_eol);
150            line.wire.prepend(wire.into_static());
151            return Ok((line, tail));
152        }
153
154        if !starts_with_wsp(tail) {
155            let mut line = Self::parse(first, eol)?;
156            line.wire.prepend(wire);
157            return Ok((line, tail));
158        }
159
160        let mut logical = Vec::from(first);
161        let mut last_eol = eol;
162
163        while starts_with_wsp(tail) {
164            let (continuation, eol, next) = physical_line(&tail[1..]);
165            wire.fold(logical.len(), is_crlf(last_eol), tail[0]);
166            logical.extend_from_slice(continuation);
167            last_eol = eol;
168            tail = next;
169        }
170
171        let mut line = Self::parse(&logical, b"")?.into_static();
172        line.eol = eol_leaf(last_eol);
173        line.wire.prepend(wire.into_static());
174
175        Ok((line, tail))
176    }
177
178    /// Split the first physical line off `rest` verbatim, content and ending.
179    ///
180    /// Returned with what follows. This is the recovering parser's step over a
181    /// line [`take`](Self::take) refuses: the bytes are kept whole rather than
182    /// structured, so they still round-trip.
183    pub fn take_physical(rest: &'a [u8]) -> (&'a [u8], &'a [u8]) {
184        let (content, eol, tail) = physical_line(rest);
185        (&rest[..content.len() + eol.len()], tail)
186    }
187
188    /// Convert into an owned line whose every leaf is owned (`'static`).
189    pub(crate) fn into_static(self) -> IcalLine<'static> {
190        IcalLine {
191            name: self.name.into_static(),
192            params: self
193                .params
194                .into_iter()
195                .map(IcalParamNode::into_static)
196                .collect(),
197            value: self.value.into_static(),
198            eol: self.eol.into_static(),
199            wire: self.wire.into_static(),
200        }
201    }
202
203    /// The raw bytes of the line's first value, for simple single-value lines.
204    pub fn raw_value(&self) -> &[u8] {
205        self.value.first_value_bytes()
206    }
207
208    /// The raw first value as UTF-8 text, lossily; for the ASCII envelope
209    /// values (`VERSION`) and diagnostics.
210    pub fn raw_value_str(&self) -> Cow<'_, str> {
211        String::from_utf8_lossy(self.value.first_value_bytes())
212    }
213
214    /// Serialize the whole line to bytes, exactly as parsed: its logical
215    /// content, laid back out in the wire shape it arrived in.
216    pub(crate) fn write_bytes(&self, out: &mut Vec<u8>) {
217        if self.wire.is_empty() {
218            self.write_logical(out);
219        } else {
220            let mut logical = Vec::new();
221            self.write_logical(&mut logical);
222            self.wire.write_bytes(&logical, out);
223        }
224
225        out.extend_from_slice(self.eol.get().as_bytes());
226    }
227
228    /// Serialize the logical line (its name, parameters and value), with no
229    /// line ending and no wire shape. This is the byte string the wire offsets
230    /// index.
231    fn write_logical(&self, out: &mut Vec<u8>) {
232        out.extend_from_slice(self.name.get().as_bytes());
233
234        for param in &self.params {
235            out.push(b';');
236            param.write_bytes(out);
237        }
238
239        out.push(b':');
240        self.value.write_bytes(out);
241    }
242
243    /// The first parameter of type `P`, decoded.
244    pub fn param<P: IcalParamLens>(&self) -> Option<P::Target<'_>> {
245        self.params
246            .iter()
247            .find(|param| param.name.get().eq_ignore_ascii_case(&P::KIND))
248            .map(|param| P::decode(param))
249    }
250
251    /// The first parameter of type `P`, mutably (raw, for editing its leaves).
252    pub fn param_mut<P: IcalParamLens>(&mut self) -> Option<&mut IcalParamNode<'a>> {
253        self.params
254            .iter_mut()
255            .find(|param| param.name.get().eq_ignore_ascii_case(&P::KIND))
256    }
257
258    /// Split one logical line into a typed line at the colon, separating the
259    /// name, its parameters and the value. The head (name and parameters) must
260    /// be valid UTF-8, as every version's grammar guarantees; only the value
261    /// may carry a foreign charset, so it is kept as raw bytes.
262    fn parse<'b>(content: &'b [u8], eol: &'b [u8]) -> Result<IcalLine<'b>, IcalParseError> {
263        let Some(colon) = value_colon(content) else {
264            return Err(IcalParseError::MissingPropertyColon(lossy(content)));
265        };
266
267        let head = str::from_utf8(&content[..colon])
268            .map_err(|_| IcalParseError::NonUtf8Header(lossy(&content[..colon])))?;
269        let (name, params) = split_head(head);
270
271        let mut value = &content[colon + 1..];
272        let mut wire = IcalWire::default();
273
274        // NOTE: A QUOTED-PRINTABLE value ending in `=` is a dangling soft-break
275        // marker, however it got there (a soft-break join, a folded
276        // continuation, or raw input): valid content would encode a literal `=`
277        // as `=3D`. Left in, it would re-trigger soft-break joining on reparse
278        // and swallow the next line, so the logical line drops it and the wire
279        // shape keeps it. This never touches base64 padding, since
280        // `ENCODING=BASE64` is not quoted-printable.
281        if head_is_quoted_printable(content) {
282            let full = value.len();
283            while value.last() == Some(&b'=') {
284                value = &value[..value.len() - 1];
285            }
286            if value.len() < full {
287                let end = colon + 1 + value.len();
288                wire.skipped(end, ascii(&content[end..colon + 1 + full]));
289            }
290        }
291
292        wire.seal(colon + 1 + value.len());
293
294        Ok(IcalLine {
295            name: IcalLeaf::from(name),
296            params,
297            value: IcalValueNode::parse(value),
298            eol: IcalLeaf::from(str::from_utf8(eol).unwrap_or("")),
299            wire,
300        })
301    }
302}
303
304impl fmt::Display for IcalLine<'_> {
305    /// The line as text, wire shape included, lossily for a non-UTF-8 value.
306    /// `IcalLine::write_bytes` is the byte-faithful path.
307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308        if self.wire.is_empty() {
309            f.write_str(self.name.get())?;
310
311            for param in &self.params {
312                write!(f, ";{param}")?;
313            }
314
315            return write!(f, ":{}{}", self.value, self.eol.get());
316        }
317
318        let mut bytes = Vec::new();
319        self.write_bytes(&mut bytes);
320        f.write_str(&String::from_utf8_lossy(&bytes))
321    }
322}
323
324/// Split a head into its name and its `;`-separated parameters.
325fn split_head(head: &str) -> (&str, Vec<IcalParamNode<'_>>) {
326    let (name, mut rest) = match param_semicolon(head) {
327        Some(semi) => (&head[..semi], &head[semi..]),
328        None => return (head, Vec::new()),
329    };
330
331    let mut params = Vec::new();
332
333    while let Some(after) = rest.strip_prefix(';') {
334        let (param, tail) = match param_semicolon(after) {
335            Some(semi) => (&after[..semi], &after[semi..]),
336            None => (after, ""),
337        };
338
339        params.push(IcalParamNode::parse(param));
340        rest = tail;
341    }
342
343    (name, params)
344}
345
346/// The index of the `:` separating a line's head from its value.
347///
348/// The first one outside a double-quoted parameter value, which RFC 5545 3.2
349/// lets carry both a colon and a semicolon. A head with an unbalanced quote
350/// would swallow the rest of the line, so with no colon outside quotes the
351/// scan falls back to the first colon anywhere and the line still parses.
352fn value_colon(content: &[u8]) -> Option<usize> {
353    let mut quoted = false;
354
355    for (i, &byte) in content.iter().enumerate() {
356        match byte {
357            b'"' => quoted = !quoted,
358            b':' if !quoted => return Some(i),
359            _ => {}
360        }
361    }
362
363    memchr::memchr(b':', content)
364}
365
366/// The byte index of the first `;` of a head that sits outside a double-quoted
367/// parameter value, the one separating one parameter from the next.
368fn param_semicolon(head: &str) -> Option<usize> {
369    let mut quoted = false;
370
371    for (i, byte) in head.bytes().enumerate() {
372        match byte {
373            b'"' => quoted = !quoted,
374            b';' if !quoted => return Some(i),
375            _ => {}
376        }
377    }
378
379    None
380}
381
382/// Split the first physical line off `rest`: its content (without the line
383/// ending), its line ending, and the remaining input. A final line with no
384/// trailing break is taken whole, with an empty ending.
385fn physical_line(rest: &[u8]) -> (&[u8], &[u8], &[u8]) {
386    let Some(lf) = memchr::memchr(b'\n', rest) else {
387        return (rest, b"", b"");
388    };
389
390    let tail = &rest[lf + 1..];
391
392    let (content, eol) = if lf > 0 && rest[lf - 1] == b'\r' {
393        (&rest[..lf - 1], &rest[lf - 1..lf + 1])
394    } else {
395        (&rest[..lf], &rest[lf..lf + 1])
396    };
397
398    (content, eol, tail)
399}
400
401/// Whether `rest` begins with a folding whitespace (space or tab).
402fn starts_with_wsp(rest: &[u8]) -> bool {
403    matches!(rest.first(), Some(b' ' | b'\t'))
404}
405
406/// Strip any leading folding whitespace (space, tab) or stray line-break byte
407/// (`\r`, `\n`) from a line's content, so its name never begins with a byte
408/// that another layer (folding, blank-line skipping) would re-strip on reparse.
409fn strip_leading_wsp(mut bytes: &[u8]) -> &[u8] {
410    while matches!(bytes.first(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
411        bytes = &bytes[1..];
412    }
413    bytes
414}
415
416/// Whether a line's head (its name and parameters, before the `:`) declares the
417/// `QUOTED-PRINTABLE` encoding, as an `ENCODING=` parameter or a bare token.
418fn head_is_quoted_printable(line: &[u8]) -> bool {
419    let head = match value_colon(line) {
420        Some(colon) => &line[..colon],
421        None => return false,
422    };
423
424    head.split(|&b| b == b';').any(|token| {
425        token.eq_ignore_ascii_case(b"QUOTED-PRINTABLE")
426            || token.eq_ignore_ascii_case(b"ENCODING=QUOTED-PRINTABLE")
427    })
428}
429
430/// An owned line-ending leaf from raw bytes (always an ASCII `\r\n` / `\n`).
431fn eol_leaf(bytes: &[u8]) -> IcalLeaf<'static> {
432    IcalLeaf::from(String::from_utf8_lossy(bytes).into_owned())
433}
434
435/// Whether a line ending is a `\r\n` rather than a bare `\n`.
436fn is_crlf(eol: &[u8]) -> bool {
437    eol.starts_with(b"\r")
438}
439
440/// Bytes the tokeniser resolved away, as text. Every one of them is a line
441/// break, a space, a tab or an `=`, so the conversion never fails; a lone `""`
442/// on the impossible path keeps this total rather than panicking.
443fn ascii(bytes: &[u8]) -> &str {
444    str::from_utf8(bytes).unwrap_or("")
445}
446
447/// A lossy owned string of raw bytes, for error diagnostics.
448fn lossy(bytes: &[u8]) -> String {
449    String::from_utf8_lossy(bytes).into_owned()
450}
451
452#[cfg(test)]
453mod tests {
454    use alloc::string::ToString;
455
456    use crate::tree::{line::IcalLine, value::node::IcalValueNode};
457
458    #[test]
459    fn takes_one_line_and_leaves_the_rest() {
460        let (line, rest) = IcalLine::take(b"FN:John\r\nEND:VCALENDAR\r\n").unwrap();
461        assert_eq!(line.name.get(), "FN");
462        assert_eq!(line.to_string(), "FN:John\r\n");
463        assert_eq!(rest, b"END:VCALENDAR\r\n");
464    }
465
466    #[test]
467    fn splits_parameters_off_the_head_then_round_trips() {
468        let (line, _) = IcalLine::take(b"TEL;TYPE=work,home:123\r\n").unwrap();
469        assert_eq!(line.params.len(), 1);
470        assert_eq!(line.to_string(), "TEL;TYPE=work,home:123\r\n");
471    }
472
473    #[test]
474    fn keeps_a_quoted_parameter_value_whole() {
475        // NOTE: RFC 5545 section 3.2 lets a quoted parameter value carry a
476        // colon; section 3.2.1 uses one in its ALTREP example.
477        let raw = "DESCRIPTION;ALTREP=\"cid:part1.0001@example.org\";LANGUAGE=en:Meeting notes\r\n";
478        let (line, _) = IcalLine::take(raw.as_bytes()).unwrap();
479
480        assert_eq!(line.name.get(), "DESCRIPTION");
481        assert_eq!(line.params.len(), 2);
482        assert_eq!(line.params[0].name.get(), "ALTREP");
483        assert_eq!(
484            line.params[0].values[0].get(),
485            "\"cid:part1.0001@example.org\""
486        );
487        assert_eq!(line.params[1].name.get(), "LANGUAGE");
488        assert_eq!(line.raw_value_str(), "Meeting notes");
489        assert_eq!(line.to_string(), raw);
490    }
491
492    #[test]
493    fn keeps_a_quoted_semicolon_out_of_the_parameter_split() {
494        let raw = "ATTENDEE;DIR=\"ldap://host:389/cn=Ada;o=Example\":mailto:ada@example.com\r\n";
495        let (line, _) = IcalLine::take(raw.as_bytes()).unwrap();
496
497        assert_eq!(line.params.len(), 1);
498        assert_eq!(line.params[0].name.get(), "DIR");
499        assert_eq!(line.raw_value_str(), "mailto:ada@example.com");
500        assert_eq!(line.to_string(), raw);
501    }
502
503    #[test]
504    fn an_unbalanced_quote_still_parses() {
505        // NOTE: quote tracking alone would swallow the rest of the line, so
506        // with no colon outside quotes the scan falls back to the first one.
507        let raw = "ATTENDEE;CN=\"Ada:mailto:ada@example.com\r\n";
508        let (line, _) = IcalLine::take(raw.as_bytes()).unwrap();
509
510        assert_eq!(line.name.get(), "ATTENDEE");
511        assert_eq!(line.to_string(), raw);
512    }
513
514    #[test]
515    fn accepts_a_bare_lf_ending() {
516        let (line, _) = IcalLine::take(b"FN:John\n").unwrap();
517        assert_eq!(line.to_string(), "FN:John\n");
518    }
519
520    #[test]
521    fn unfolds_space_and_tab_continuations() {
522        let (line, rest) =
523            IcalLine::take(b"NOTE:foo\r\n bar\r\n\tbaz\r\nEND:VCALENDAR\r\n").unwrap();
524        assert_eq!(line.name.get(), "NOTE");
525        assert_eq!(line.raw_value_str(), "foobarbaz");
526        assert_eq!(rest, b"END:VCALENDAR\r\n");
527    }
528
529    #[test]
530    fn serializes_a_folded_line_back_folded() {
531        let (line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
532        assert_eq!(line.raw_value_str(), "foobar");
533        assert_eq!(line.to_string(), "NOTE:foo\r\n bar\r\n");
534    }
535
536    #[test]
537    fn keeps_the_folding_whitespace_and_the_break_it_arrived_with() {
538        let (line, _) = IcalLine::take(b"NOTE:foo\n\tbar\r\n").unwrap();
539        assert_eq!(line.to_string(), "NOTE:foo\n\tbar\r\n");
540    }
541
542    #[test]
543    fn serializes_a_skipped_blank_line_back() {
544        let (line, _) = IcalLine::take(b"\r\n\r\nFN:John\r\n").unwrap();
545        assert_eq!(line.to_string(), "\r\n\r\nFN:John\r\n");
546    }
547
548    #[test]
549    fn drops_the_fold_points_once_the_value_is_edited() {
550        // NOTE: The old offsets index bytes that are no longer there, so the
551        // edited line goes out unfolded rather than folded in the wrong places.
552        let (mut line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
553        line.value = IcalValueNode::parse(b"something else entirely");
554        assert_eq!(line.to_string(), "NOTE:something else entirely\r\n");
555    }
556
557    #[test]
558    fn keeps_the_fold_points_when_an_edit_keeps_the_length() {
559        let (mut line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
560        line.value = IcalValueNode::parse(b"BARFOO");
561        assert_eq!(line.to_string(), "NOTE:BAR\r\n FOO\r\n");
562    }
563
564    #[test]
565    fn keeps_whitespace_beyond_the_single_fold_indicator() {
566        let (line, _) = IcalLine::take(b"NOTE:foo\r\n  bar\r\n").unwrap();
567        assert_eq!(line.raw_value_str(), "foo bar");
568    }
569
570    #[test]
571    fn skips_blank_lines_before_the_next_line() {
572        let (line, rest) = IcalLine::take(b"\r\n\r\nFN:John\r\nEND:VCALENDAR\r\n").unwrap();
573        assert_eq!(line.name.get(), "FN");
574        assert_eq!(rest, b"END:VCALENDAR\r\n");
575    }
576
577    #[test]
578    fn tolerates_a_missing_final_line_break() {
579        let (line, rest) = IcalLine::take(b"END:VCALENDAR").unwrap();
580        assert_eq!(line.name.get(), "END");
581        assert_eq!(line.to_string(), "END:VCALENDAR");
582        assert_eq!(rest, b"");
583    }
584
585    #[test]
586    fn joins_a_quoted_printable_soft_broken_line() {
587        // NOTE: Two soft breaks: the first continuation itself ends with `=`
588        // (the Some arm), the second does not (the None arm).
589        let (line, _) = IcalLine::take(
590            b"NOTE;ENCODING=QUOTED-PRINTABLE:caf=\r\n=C3=\r\n=A9\r\nEND:VCALENDAR\r\n",
591        )
592        .unwrap();
593        assert_eq!(line.name.get(), "NOTE");
594        assert_eq!(line.raw_value_str(), "caf=C3=A9");
595        assert_eq!(line.raw_value(), b"caf=C3=A9");
596    }
597
598    #[test]
599    fn errors_when_there_is_no_content_line() {
600        assert!(IcalLine::take(b"").is_err());
601        assert!(IcalLine::take(b"\r\n\r\n").is_err());
602    }
603
604    #[test]
605    fn rejects_a_non_utf8_head() {
606        let mut raw = b"X-".to_vec();
607        raw.push(0xff);
608        raw.extend_from_slice(b":v\r\n");
609        assert!(IcalLine::take(&raw).is_err());
610    }
611
612    #[test]
613    fn finds_a_parameter_mutably() {
614        use crate::tree::param::language::LANGUAGE;
615
616        let (mut line, _) = IcalLine::take(b"SUMMARY;LANGUAGE=en:Lunch\r\n").unwrap();
617        assert!(line.param_mut::<LANGUAGE>().is_some());
618    }
619
620    #[test]
621    fn a_trailing_equals_without_a_colon_is_not_quoted_printable() {
622        // NOTE: `abc=` ends with `=` but has no colon, so the QP soft-break
623        // check bails and the line then fails for want of a value separator.
624        assert!(IcalLine::take(b"abc=\r\n").is_err());
625    }
626
627    #[test]
628    fn quoted_printable_join_stops_at_an_empty_tail() {
629        // NOTE: The final continuation ends with `=` and nothing follows, so
630        // the join loop exits via the empty-tail guard rather than a non-`=`
631        // line.
632        let (line, rest) = IcalLine::take(b"NOTE;ENCODING=QUOTED-PRINTABLE:a=\r\nb=\r\n").unwrap();
633        assert_eq!(line.raw_value_str(), "ab");
634        assert_eq!(rest, b"");
635    }
636}