Skip to main content

ical/tree/value/
cursor.rs

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