ical/jcal.rs
1//! # jCal
2//!
3//! The RFC 7265 jCal codec: the decoded calendar as JSON, and back.
4//!
5//! jCal is the JSON spelling of the iCalendar model. A component is
6//! `[name, [properties], [components]]` and a property is
7//! `[name, {params}, type, value...]` (RFC 7265 3.3, 3.4).
8//!
9//! [`Ical::to_jcal`] writes the decoded model as a [`serde_json::Value`];
10//! [`Ical::from_jcal`] reads one back, borrowing the JSON tree's strings.
11//!
12//! Import resolves each property's value kind through the same spec vtable as
13//! the wire decoder, so a jCal and the calendar it was written from decode to
14//! the same model.
15//!
16//! The boundary is a raw `Value`, not a serde implementation on any calendar
17//! type. One model has two JSON spellings here, jCal and JSCalendar (behind
18//! the `jscalendar` feature), and serde keys one representation per type, so
19//! it is the wrong tool.
20//!
21//! A raw-value boundary also keeps the public API free of a serialization
22//! commitment.
23//!
24//! ## Postel, again
25//!
26//! On the way out the RFC is followed: names are lowercased, the `VALUE`
27//! parameter moves into the type slot (RFC 7265 3.5.4), and dates, times,
28//! periods, offsets and recurrence rules are re-spelled in the JSON forms
29//! (3.5.1 to 3.5.7).
30//!
31//! On the way in anything is accepted: an unknown name, an unknown parameter
32//! and an unrecognised type slot all survive, a non-string scalar is coerced
33//! to text, and a missing part is an empty one.
34//!
35//! ## What round-trips, and what normalises
36//!
37//! A calendar written to jCal and read back decodes to the same model, with
38//! three normalisations that are the JSON format's, not the codec's.
39//!
40//! Parameter order is lost to the JSON object, a recurrence rule comes back
41//! with its parts in the RFC's canonical order (a JSON object has no order to
42//! preserve), and names come back in their canonical spelling.
43//!
44//! Byte fidelity is the syntax tree's job; jCal is a projection of the
45//! decoded model.
46
47pub(crate) mod datetime;
48mod export;
49mod import;
50mod json;
51mod recur;
52
53use core::{error, fmt};
54
55use alloc::{
56 string::{String, ToString},
57 vec,
58 vec::Vec,
59};
60
61use serde_json::{Map, Value, json};
62
63use crate::{
64 component::IcalComponent, ical::Ical, jcal::import::split_component, prop::IcalProp,
65 value::IcalValue, version::IcalVersion,
66};
67
68/// What a jCal value cannot be read as.
69///
70/// Only the shape of the document is refused; everything inside it is read
71/// liberally, so this is a short list on purpose.
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub enum IcalJcalError {
74 /// The document is not a `[name, [properties], [components]]` array.
75 NotAComponent,
76 /// The outermost component is not a `vcalendar`.
77 NotACalendar(String),
78}
79
80impl fmt::Display for IcalJcalError {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 match self {
83 Self::NotAComponent => {
84 f.write_str("jCal value is not a [name, [properties], [components]] array")
85 }
86 Self::NotACalendar(name) => {
87 write!(f, "jCal document is a `{name}`, not a `vcalendar`")
88 }
89 }
90 }
91}
92
93impl error::Error for IcalJcalError {}
94
95impl Ical<'_> {
96 /// The calendar as an RFC 7265 jCal value.
97 ///
98 /// `VERSION` leads the property list, as it does on the wire, carrying the
99 /// calendar's own version rather than a fixed one.
100 pub fn to_jcal(&self) -> Value {
101 let mut props = vec![json!([
102 "version",
103 Map::new(),
104 "text",
105 Value::String((*self.version).to_string())
106 ])];
107 props.extend(self.props.iter().map(IcalProp::to_jcal));
108
109 let components: Vec<Value> = self.components.iter().map(IcalComponent::to_jcal).collect();
110
111 json!(["vcalendar", props, components])
112 }
113}
114
115impl<'a> Ical<'a> {
116 /// Read a calendar back from an RFC 7265 jCal value, borrowing its strings.
117 pub fn from_jcal(jcal: &'a Value) -> Result<Self, IcalJcalError> {
118 let (name, props, components) =
119 split_component(jcal).ok_or(IcalJcalError::NotAComponent)?;
120
121 if !name.eq_ignore_ascii_case("vcalendar") {
122 return Err(IcalJcalError::NotACalendar(name.to_string()));
123 }
124
125 let mut version = IcalVersion::V2_0;
126 let mut decoded = Vec::new();
127
128 for entry in props {
129 let prop = IcalProp::from_jcal(entry, version);
130
131 // NOTE: VERSION is the hoisted-out indicator, never a property of
132 // the model (see `Ical::props`).
133 if prop.name.eq_ignore_ascii_case("VERSION") {
134 if let IcalValue::Text(text) = &prop.value {
135 version = text.0.parse().unwrap_or(IcalVersion::V2_0);
136 }
137 continue;
138 }
139
140 decoded.push(prop);
141 }
142
143 Ok(Ical {
144 version,
145 props: decoded,
146 components: components
147 .iter()
148 .map(|component| IcalComponent::from_jcal(component, version))
149 .collect(),
150 })
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use alloc::{borrow::Cow, vec};
157
158 use crate::{
159 component::{IcalComponent, IcalComponentKind},
160 ical::Ical,
161 jcal::IcalJcalError,
162 param::IcalParam,
163 prop::{IcalProp, IcalPropKind},
164 value::{IcalValue, datetime::IcalDateTime, text::IcalText},
165 version::IcalVersion,
166 };
167
168 /// A hand-built calendar, so the codec is exercised with no parser.
169 fn calendar() -> Ical<'static> {
170 Ical {
171 version: IcalVersion::V2_0,
172 props: vec![IcalProp {
173 name: IcalPropKind::ProdId.into(),
174 params: vec![],
175 value: IcalValue::Text(IcalText(Cow::Borrowed("-//Example//EN"))),
176 }],
177 components: vec![IcalComponent {
178 name: IcalComponentKind::VEvent.into(),
179 props: vec![
180 IcalProp {
181 name: IcalPropKind::Uid.into(),
182 params: vec![],
183 value: IcalValue::Text(IcalText(Cow::Borrowed("42@example.com"))),
184 },
185 IcalProp {
186 name: IcalPropKind::DtStart.into(),
187 params: vec![],
188 value: IcalValue::DateTime(IcalDateTime(Cow::Borrowed("20260102T120000Z"))),
189 },
190 IcalProp {
191 name: IcalPropKind::Summary.into(),
192 params: vec![IcalParam::Language(Cow::Borrowed("en"))],
193 value: IcalValue::Text(IcalText(Cow::Borrowed("Lunch"))),
194 },
195 ],
196 components: vec![],
197 }],
198 }
199 }
200
201 #[test]
202 fn round_trips_a_calendar_built_with_no_parser() {
203 let cal = calendar();
204 let jcal = cal.to_jcal();
205
206 assert_eq!(Ical::from_jcal(&jcal).expect("a vcalendar"), cal);
207 }
208
209 #[test]
210 fn refuses_a_document_that_is_not_a_vcalendar() {
211 let jcal = serde_json::json!(["vevent", [], []]);
212
213 assert_eq!(
214 Ical::from_jcal(&jcal),
215 Err(IcalJcalError::NotACalendar("vevent".into()))
216 );
217 }
218}