Skip to main content

ical/tree/value/
cursor.rs

1//! # Value cursor
2//!
3//! The in-place edit cursor every property lens uses.
4//!
5//! A cursor borrows a content line mutably and reads and writes its value
6//! through the codec: getters decode (unescape), setters encode (escape) and
7//! write through to the syntax node.
8//!
9//! A setter only rewrites the component it touches, so every other leaf (and
10//! every parameter) of a parsed line stays byte for byte intact.
11//!
12//! [`IcalValueCursor`] offers convenience accessors for the common
13//! single-value and list shapes, plus component-level access for the
14//! structured values (`GEO`, `REQUEST-STATUS`).
15//!
16//! Beside the UTF-8 text accessors it offers a raw byte hatch
17//! ([`bytes`](IcalValueCursor::bytes) /
18//! [`set_bytes`](IcalValueCursor::set_bytes)) for a value in a foreign
19//! charset.
20//!
21//! Behind the content-encoding features sit the
22//! [`quoted_printable`](IcalValueCursor::quoted_printable) and
23//! [`charset`](IcalValueCursor::charset) decoders.
24
25#[cfg(feature = "encoding")]
26use alloc::string::String;
27use alloc::{borrow::Cow, vec::Vec};
28
29use crate::tree::{line::IcalLine, param::lens::IcalParamLens};
30
31/// A typed cursor over a content line's value, editing in place and byte
32/// preserving for the components it does not touch.
33pub struct IcalValueCursor<'c, 'a> {
34    /// The borrowed content line.
35    pub line: &'c mut IcalLine<'a>,
36}
37
38impl IcalValueCursor<'_, '_> {
39    /// The whole value as a single decoded text, its `;` and `,` kept literal.
40    pub fn text(&self) -> Cow<'_, str> {
41        self.line.value.decode()
42    }
43
44    /// Set the whole value to a single text, escaping it. Writes UTF-8; to keep
45    /// a foreign charset, transcode yourself and use
46    /// [`set_bytes`](Self::set_bytes).
47    pub fn set_text(&mut self, value: impl AsRef<str>) {
48        self.line.value.set(&[value]);
49    }
50
51    /// The whole value's raw bytes, unescaped but not otherwise decoded.
52    ///
53    /// Neither transcoded nor transfer-decoded, for a value carrying a foreign
54    /// charset. To resolve `QUOTED-PRINTABLE` or a `CHARSET`, use the
55    /// [`quoted_printable`](Self::quoted_printable) /
56    /// [`charset`](Self::charset) feature helpers.
57    pub fn bytes(&self) -> Cow<'_, [u8]> {
58        self.line.value.decode_bytes()
59    }
60
61    /// Set the whole value to raw bytes (the foreign-charset escape hatch),
62    /// escaping structural separators but writing the bytes verbatim. The
63    /// calendar's `CHARSET` parameter is left untouched: it is the caller's to
64    /// keep consistent.
65    pub fn set_bytes(&mut self, value: impl AsRef<[u8]>) {
66        self.line.value.set_bytes(&[value]);
67    }
68
69    /// Decode the value's `QUOTED-PRINTABLE` `=XX` octets to raw bytes.
70    ///
71    /// Only when the line declares that encoding, else the raw
72    /// [`bytes`](Self::bytes). Still in the value's own (possibly foreign)
73    /// charset; pair with [`charset`](Self::charset) to get text. Requires the
74    /// `quoted-printable` feature.
75    #[cfg(feature = "quoted-printable")]
76    pub fn quoted_printable(&self) -> Vec<u8> {
77        let raw = self.bytes();
78
79        if self.line.is_quoted_printable() {
80            quoted_printable::decode(raw.as_ref(), quoted_printable::ParseMode::Robust)
81                .unwrap_or_else(|_| raw.into_owned())
82        } else {
83            raw.into_owned()
84        }
85    }
86
87    /// Transcode the value to text using its `CHARSET` parameter (defaulting to
88    /// UTF-8 when absent or unrecognised). When the `quoted-printable` feature
89    /// is also on, `QUOTED-PRINTABLE` octets are resolved first. Requires the
90    /// `encoding` feature.
91    #[cfg(feature = "encoding")]
92    pub fn charset(&self) -> String {
93        #[cfg(feature = "quoted-printable")]
94        let bytes = self.quoted_printable();
95        #[cfg(not(feature = "quoted-printable"))]
96        let bytes = self.bytes().into_owned();
97
98        let encoding = self
99            .line
100            .charset_label()
101            .and_then(|label| encoding_rs::Encoding::for_label(label.as_bytes()))
102            .unwrap_or(encoding_rs::UTF_8);
103
104        encoding.decode_without_bom_handling(&bytes).0.into_owned()
105    }
106
107    /// The whole value as a decoded list (its `,`-separated values), its `;`
108    /// kept literal.
109    pub fn list(&self) -> Vec<Cow<'_, str>> {
110        self.line.value.decode_list()
111    }
112
113    /// Set the whole value to a list, escaping each value.
114    pub fn set_list<S: AsRef<str>>(&mut self, values: &[S]) {
115        self.line.value.set(values);
116    }
117
118    /// The `i`th component as a decoded list, for structured values.
119    pub fn component(&self, i: usize) -> Vec<Cow<'_, str>> {
120        self.line.value.decode_component_list(i)
121    }
122
123    /// Set the `i`th component, escaping each value and preserving the rest.
124    pub fn set_component<S: AsRef<str>>(&mut self, i: usize, values: &[S]) {
125        self.line.value.set_component(i, values);
126    }
127
128    /// The first parameter of type `P` on this line, decoded.
129    pub fn param<P: IcalParamLens>(&self) -> Option<P::Target<'_>> {
130        self.line.param::<P>()
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use alloc::{
137        format,
138        string::{String, ToString},
139        vec,
140    };
141
142    use crate::{prop::summary::SUMMARY, tree::cst::IcalCst};
143
144    const HEAD: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n";
145    const TAIL: &str = "END:VCALENDAR\r\n";
146
147    fn cal(prop_line: &str) -> String {
148        format!("{HEAD}{prop_line}\r\n{TAIL}")
149    }
150
151    #[test]
152    fn edits_a_scalar_value_in_place_escaping_it() {
153        let raw = cal("SUMMARY:Lunch");
154        let mut c = IcalCst::parse(&raw).unwrap();
155        c.prop_mut::<SUMMARY>().unwrap().set_text("Tea, now");
156        assert!(c.to_string().contains("SUMMARY:Tea\\, now\r\n"));
157    }
158
159    #[test]
160    fn keeps_a_newline_written_into_a_vcalendar_1_0_value_on_its_line() {
161        // NOTE: Versit has no newline escape, and the raw byte would end the
162        // line, leaving a calendar the parser refuses.
163        let raw = "BEGIN:VCALENDAR\r\nVERSION:1.0\r\nSUMMARY:x\r\nEND:VCALENDAR\r\n";
164        let mut c = IcalCst::parse(raw.as_bytes()).unwrap();
165        c.prop_mut::<SUMMARY>().unwrap().set_text("a\nb");
166
167        assert!(c.to_string().contains("SUMMARY:a\\nb\r\n"));
168        assert!(IcalCst::parse(&c.to_bytes()).is_ok());
169    }
170
171    #[test]
172    fn writes_and_reads_a_foreign_charset_value_as_raw_bytes() {
173        use crate::prop::description::DESCRIPTION;
174
175        let raw = cal("DESCRIPTION;CHARSET=ISO-8859-1:x");
176        let mut c = IcalCst::parse(&raw).unwrap();
177
178        // NOTE: "café" in ISO-8859-1: the trailing 0xE9 is not valid UTF-8.
179        let latin1 = [b'c', b'a', b'f', 0xE9];
180        c.prop_mut::<DESCRIPTION>().unwrap().set_bytes(latin1);
181
182        assert_eq!(
183            c.prop_mut::<DESCRIPTION>().unwrap().bytes().as_ref(),
184            &latin1,
185        );
186        assert!(c.to_bytes().windows(4).any(|window| window == latin1));
187    }
188
189    #[cfg(feature = "quoted-printable")]
190    #[test]
191    fn quoted_printable_helper_resolves_octets() {
192        use crate::prop::description::DESCRIPTION;
193
194        let raw = cal("DESCRIPTION;CHARSET=ISO-8859-1;ENCODING=QUOTED-PRINTABLE:caf=E9");
195        let mut c = IcalCst::parse(&raw).unwrap();
196
197        assert_eq!(
198            c.prop_mut::<DESCRIPTION>().unwrap().quoted_printable(),
199            [b'c', b'a', b'f', 0xE9],
200        );
201    }
202
203    #[cfg(all(feature = "encoding", feature = "quoted-printable"))]
204    #[test]
205    fn charset_helper_transcodes_to_utf8() {
206        use crate::prop::description::DESCRIPTION;
207
208        let raw = cal("DESCRIPTION;CHARSET=ISO-8859-1;ENCODING=QUOTED-PRINTABLE:caf=E9");
209        let mut c = IcalCst::parse(&raw).unwrap();
210
211        assert_eq!(c.prop_mut::<DESCRIPTION>().unwrap().charset(), "café");
212    }
213
214    #[test]
215    fn edits_one_structured_component_preserving_the_rest() {
216        use crate::prop::geo::GEO;
217
218        let raw = cal("GEO:37.0;-122.0");
219        let mut c = IcalCst::parse(&raw).unwrap();
220        c.prop_mut::<GEO>().unwrap().set_component(1, &["-100.0"]);
221        assert!(c.to_string().contains("GEO:37.0;-100.0\r\n"));
222    }
223
224    /// The generic accessors read and write the value, not its first slot.
225    ///
226    /// A semicolon separates nothing in a text value, so a read that stopped
227    /// at one handed back a truncated value and a write that rewrote only the
228    /// first component left the rest behind: read then write changed it.
229    #[test]
230    fn reads_and_writes_the_whole_value_not_its_first_component() {
231        use crate::prop::description::DESCRIPTION;
232
233        let raw = cal("DESCRIPTION:a;b");
234        let mut c = IcalCst::parse(&raw).unwrap();
235
236        {
237            let cursor = c.prop_mut::<DESCRIPTION>().unwrap();
238            assert_eq!(cursor.text(), "a;b");
239            assert_eq!(cursor.bytes().as_ref(), b"a;b");
240            assert_eq!(cursor.list(), vec!["a;b"]);
241        }
242
243        let whole = c.prop_mut::<DESCRIPTION>().unwrap().text().into_owned();
244        c.prop_mut::<DESCRIPTION>().unwrap().set_text(&whole);
245
246        assert!(c.to_string().contains("DESCRIPTION:a\\;b\r\n"), "got: {c}");
247        assert_eq!(c.prop_mut::<DESCRIPTION>().unwrap().text(), "a;b");
248    }
249
250    /// A structured value read through its lens keeps its components' commas.
251    #[test]
252    fn reads_a_structured_component_past_its_first_comma() {
253        use crate::prop::request_status::REQUEST_STATUS;
254
255        let raw = cal("REQUEST-STATUS:2.0;ok;rcpt,two");
256        let c = IcalCst::parse(&raw).unwrap();
257        let status = c.prop::<REQUEST_STATUS>().unwrap();
258
259        assert_eq!(status.description, "ok");
260        assert_eq!(status.extra, "rcpt,two");
261    }
262
263    #[test]
264    fn exercises_every_generic_accessor() {
265        use crate::prop::categories::CATEGORIES;
266
267        let raw = cal("CATEGORIES:a,b");
268        let mut c = IcalCst::parse(&raw).unwrap();
269
270        {
271            let mut cursor = c.prop_mut::<CATEGORIES>().unwrap();
272
273            // NOTE: A text read takes the whole value and a list read splits it
274            // on its commas, both keeping every `;` the value carries, while a
275            // component read takes one `;`-separated slot.
276            assert_eq!(cursor.text(), "a,b");
277            assert_eq!(cursor.list(), vec!["a", "b"]);
278            assert_eq!(cursor.component(0), vec!["a", "b"]);
279
280            cursor.set_text("x");
281            assert_eq!(cursor.text(), "x");
282
283            cursor.set_list(&["a", "b"]);
284            assert_eq!(cursor.list(), vec!["a", "b"]);
285
286            // A component past the last one extends the value rather than
287            // dropping the write.
288            cursor.set_component(1, &["y"]);
289            assert_eq!(cursor.component(1), vec!["y"]);
290        }
291
292        assert!(c.to_string().contains("CATEGORIES:a,b;y\r\n"), "got: {c}");
293    }
294}