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. A setter only rewrites the component it
8//! touches, so every other leaf (and every parameter) of a parsed line stays
9//! byte for byte intact. [`IcalValueCursor`] offers convenience accessors for
10//! the common single-value and list shapes, plus component-level access for the
11//! structured values (`GEO`, `REQUEST-STATUS`).
12//!
13//! Beside the UTF-8 text accessors it offers a raw byte hatch
14//! ([`bytes`](IcalValueCursor::bytes) /
15//! [`set_bytes`](IcalValueCursor::set_bytes)) for a value in a foreign charset,
16//! and, behind the content-encoding features, the
17//! [`quoted_printable`](IcalValueCursor::quoted_printable) and
18//! [`charset`](IcalValueCursor::charset) decoders.
19
20use alloc::{borrow::Cow, vec::Vec};
21
22use crate::tree::{line::IcalLine, param::lens::IcalParamLens};
23
24/// A typed cursor over a content line's value, editing in place and byte
25/// preserving for the components it does not touch.
26pub struct IcalValueCursor<'c, 'a> {
27    /// The borrowed content line.
28    pub line: &'c mut IcalLine<'a>,
29}
30
31impl IcalValueCursor<'_, '_> {
32    /// The whole value as a single decoded text (component 0, value 0).
33    pub fn text(&self) -> Cow<'_, str> {
34        self.line.value.decode_scalar_at(0)
35    }
36
37    /// Set the value to a single text, escaping and preserving any other
38    /// components. Writes UTF-8; to keep a foreign charset, transcode yourself
39    /// and use [`set_bytes`](Self::set_bytes).
40    pub fn set_text(&mut self, value: impl AsRef<str>) {
41        self.line.value.set_at(0, &[value]);
42    }
43
44    /// The whole value's raw bytes (component 0, value 0), unescaped but not
45    /// transcoded and not transfer-decoded, for a value carrying a foreign
46    /// charset. To resolve `QUOTED-PRINTABLE` or a `CHARSET`, use the
47    /// [`quoted_printable`](Self::quoted_printable) /
48    /// [`charset`](Self::charset) feature helpers.
49    pub fn bytes(&self) -> Cow<'_, [u8]> {
50        self.line.value.decode_bytes_at(0)
51    }
52
53    /// Set the value to raw bytes (the foreign-charset escape hatch), escaping
54    /// structural separators but writing the bytes verbatim and preserving any
55    /// other components. The calendar's `CHARSET` parameter is left untouched: it
56    /// is the caller's to keep consistent.
57    pub fn set_bytes(&mut self, value: impl AsRef<[u8]>) {
58        self.line.value.set_bytes_at(0, &[value]);
59    }
60
61    /// Decode the value's `QUOTED-PRINTABLE` `=XX` octets to raw bytes when the
62    /// line declares that encoding, else the raw [`bytes`](Self::bytes). Still
63    /// in the value's own (possibly foreign) charset; pair with
64    /// [`charset`](Self::charset) to get text. Requires the `quoted-printable`
65    /// feature.
66    #[cfg(feature = "quoted-printable")]
67    pub fn quoted_printable(&self) -> Vec<u8> {
68        let raw = self.bytes();
69
70        if self.line.is_quoted_printable() {
71            quoted_printable::decode(raw.as_ref(), quoted_printable::ParseMode::Robust)
72                .unwrap_or_else(|_| raw.into_owned())
73        } else {
74            raw.into_owned()
75        }
76    }
77
78    /// Transcode the value to text using its `CHARSET` parameter (defaulting to
79    /// UTF-8 when absent or unrecognised). When the `quoted-printable` feature
80    /// is also on, `QUOTED-PRINTABLE` octets are resolved first. Requires the
81    /// `encoding` feature.
82    #[cfg(feature = "encoding")]
83    pub fn charset(&self) -> alloc::string::String {
84        #[cfg(feature = "quoted-printable")]
85        let bytes = self.quoted_printable();
86        #[cfg(not(feature = "quoted-printable"))]
87        let bytes = self.bytes().into_owned();
88
89        let encoding = self
90            .line
91            .charset_label()
92            .and_then(|label| encoding_rs::Encoding::for_label(label.as_bytes()))
93            .unwrap_or(encoding_rs::UTF_8);
94
95        encoding.decode_without_bom_handling(&bytes).0.into_owned()
96    }
97
98    /// The value's first component as a decoded list (its `,`-separated
99    /// values).
100    pub fn list(&self) -> Vec<Cow<'_, str>> {
101        self.line.value.decode_at(0)
102    }
103
104    /// Set the value's first component to a list, escaping each value.
105    pub fn set_list<S: AsRef<str>>(&mut self, values: &[S]) {
106        self.line.value.set_at(0, values);
107    }
108
109    /// The `i`th component as a decoded list, for structured values.
110    pub fn component(&self, i: usize) -> Vec<Cow<'_, str>> {
111        self.line.value.decode_at(i)
112    }
113
114    /// Set the `i`th component, escaping each value and preserving the rest.
115    pub fn set_component<S: AsRef<str>>(&mut self, i: usize, values: &[S]) {
116        self.line.value.set_at(i, values);
117    }
118
119    /// The first parameter of type `P` on this line, decoded.
120    pub fn param<P: IcalParamLens>(&self) -> Option<P::Target<'_>> {
121        self.line.param::<P>()
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use alloc::string::ToString;
128
129    use crate::tree::{cst::IcalCst, prop::summary::SUMMARY};
130
131    const HEAD: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n";
132    const TAIL: &str = "END:VCALENDAR\r\n";
133
134    fn cal(prop_line: &str) -> alloc::string::String {
135        alloc::format!("{HEAD}{prop_line}\r\n{TAIL}")
136    }
137
138    #[test]
139    fn edits_a_scalar_value_in_place_escaping_it() {
140        let raw = cal("SUMMARY:Lunch");
141        let mut c = IcalCst::parse(&raw).unwrap();
142        c.prop_mut::<SUMMARY>().unwrap().set_text("Tea, now");
143        assert!(c.to_string().contains("SUMMARY:Tea\\, now\r\n"));
144    }
145
146    #[test]
147    fn writes_and_reads_a_foreign_charset_value_as_raw_bytes() {
148        use crate::tree::prop::description::DESCRIPTION;
149
150        let raw = cal("DESCRIPTION;CHARSET=ISO-8859-1:x");
151        let mut c = IcalCst::parse(&raw).unwrap();
152
153        // NOTE: "café" in ISO-8859-1: the trailing 0xE9 is not valid UTF-8.
154        let latin1 = [b'c', b'a', b'f', 0xE9];
155        c.prop_mut::<DESCRIPTION>().unwrap().set_bytes(latin1);
156
157        assert_eq!(
158            c.prop_mut::<DESCRIPTION>().unwrap().bytes().as_ref(),
159            &latin1,
160        );
161        assert!(c.to_bytes().windows(4).any(|window| window == latin1));
162    }
163
164    #[cfg(feature = "quoted-printable")]
165    #[test]
166    fn quoted_printable_helper_resolves_octets() {
167        use crate::tree::prop::description::DESCRIPTION;
168
169        let raw = cal("DESCRIPTION;CHARSET=ISO-8859-1;ENCODING=QUOTED-PRINTABLE:caf=E9");
170        let mut c = IcalCst::parse(&raw).unwrap();
171
172        assert_eq!(
173            c.prop_mut::<DESCRIPTION>().unwrap().quoted_printable(),
174            [b'c', b'a', b'f', 0xE9],
175        );
176    }
177
178    #[cfg(all(feature = "encoding", feature = "quoted-printable"))]
179    #[test]
180    fn charset_helper_transcodes_to_utf8() {
181        use crate::tree::prop::description::DESCRIPTION;
182
183        let raw = cal("DESCRIPTION;CHARSET=ISO-8859-1;ENCODING=QUOTED-PRINTABLE:caf=E9");
184        let mut c = IcalCst::parse(&raw).unwrap();
185
186        assert_eq!(c.prop_mut::<DESCRIPTION>().unwrap().charset(), "café");
187    }
188
189    #[test]
190    fn edits_one_structured_component_preserving_the_rest() {
191        use crate::tree::prop::geo::GEO;
192
193        let raw = cal("GEO:37.0;-122.0");
194        let mut c = IcalCst::parse(&raw).unwrap();
195        c.prop_mut::<GEO>().unwrap().set_component(1, &["-100.0"]);
196        assert!(c.to_string().contains("GEO:37.0;-100.0\r\n"));
197    }
198
199    #[test]
200    fn exercises_every_generic_accessor() {
201        use crate::tree::prop::categories::CATEGORIES;
202
203        let raw = cal("CATEGORIES:a,b");
204        let mut c = IcalCst::parse(&raw).unwrap();
205        let mut cursor = c.prop_mut::<CATEGORIES>().unwrap();
206
207        let _ = cursor.text();
208        let _ = cursor.list();
209        let _ = cursor.component(0);
210        cursor.set_text("x");
211        cursor.set_list(&["a", "b"]);
212        cursor.set_component(1, &["y"]);
213    }
214}