1pub mod identity;
2pub mod rule;
3pub mod schedule;
4
5use std::fmt;
6use std::str::FromStr;
7
8use chrono::{NaiveDate, NaiveTime};
9use getrandom::fill as fill_random;
10use serde::{Deserialize, Deserializer, Serialize};
11use sqlx::database::Database;
12use sqlx::decode::Decode;
13use sqlx::encode::{Encode, IsNull};
14use sqlx::error::BoxDynError;
15use sqlx::sqlite::{Sqlite, SqliteTypeInfo, SqliteValueRef};
16use sqlx::types::Type;
17
18use crate::ids::{BASE32, encode_crockford};
19
20pub use identity::{
21 RecurrenceFieldVersionSeeds, RecurrenceOccurrenceIdentity, RecurrenceOccurrenceLink,
22 derive_occurrence_identity,
23};
24pub use rule::{RecurrenceFrequency, RecurrenceRule, WeekdaySet};
25pub use schedule::{
26 RecurrenceScheduleError, RecurrenceSlotIter, is_slot, live_slot_on, next_slot_after,
27 projection_slot_at, slot_cutoff, slot_values,
28};
29
30#[cfg(test)]
31mod tests;
32
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
34#[serde(transparent)]
35pub struct RecurrenceSeriesId(String);
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct InvalidRecurrenceSeriesId;
39
40impl RecurrenceSeriesId {
41 pub fn new() -> Self {
42 let mut bytes = [0u8; 10];
43 fill_random(&mut bytes).expect("fill random bytes");
44 Self(encode_crockford(&bytes))
45 }
46
47 pub fn as_str(&self) -> &str {
48 &self.0
49 }
50}
51
52pub(crate) const SERIES_REF_PREFIX: &str = "RCR";
55
56pub(crate) fn recurrence_series_display_ref(
57 series_id: &RecurrenceSeriesId,
58 ids: &[RecurrenceSeriesId],
59) -> String {
60 let id = series_id.as_str();
61 let shared = ids
62 .iter()
63 .filter(|candidate| candidate.as_str() != id)
64 .map(|candidate| {
65 id.bytes()
66 .zip(candidate.as_str().bytes())
67 .take_while(|(left, right)| left == right)
68 .count()
69 })
70 .max()
71 .unwrap_or(0);
72 let length = 4.max(shared.saturating_add(1)).min(id.len());
73 format!("{SERIES_REF_PREFIX}-{}", &id[..length])
74}
75
76impl Default for RecurrenceSeriesId {
77 fn default() -> Self {
78 Self::new()
79 }
80}
81
82impl fmt::Display for RecurrenceSeriesId {
83 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84 self.0.fmt(formatter)
85 }
86}
87
88impl fmt::Display for InvalidRecurrenceSeriesId {
89 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90 formatter.write_str("recurrence series ID must be 16 Crockford Base32 characters")
91 }
92}
93
94impl std::error::Error for InvalidRecurrenceSeriesId {}
95
96impl FromStr for RecurrenceSeriesId {
97 type Err = InvalidRecurrenceSeriesId;
98
99 fn from_str(value: &str) -> Result<Self, Self::Err> {
100 if value.len() == 16 && value.bytes().all(|byte| BASE32.contains(&byte)) {
101 Ok(Self(value.to_string()))
102 } else {
103 Err(InvalidRecurrenceSeriesId)
104 }
105 }
106}
107
108impl TryFrom<String> for RecurrenceSeriesId {
109 type Error = InvalidRecurrenceSeriesId;
110
111 fn try_from(value: String) -> Result<Self, Self::Error> {
112 value.parse()
113 }
114}
115
116impl<'de> Deserialize<'de> for RecurrenceSeriesId {
117 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
118 where
119 D: Deserializer<'de>,
120 {
121 String::deserialize(deserializer)?
122 .parse()
123 .map_err(serde::de::Error::custom)
124 }
125}
126
127impl Type<Sqlite> for RecurrenceSeriesId {
128 fn type_info() -> SqliteTypeInfo {
129 <String as Type<Sqlite>>::type_info()
130 }
131}
132
133impl Encode<'_, Sqlite> for RecurrenceSeriesId {
134 fn encode_by_ref(
135 &self,
136 buffer: &mut <Sqlite as Database>::ArgumentBuffer,
137 ) -> Result<IsNull, BoxDynError> {
138 <String as Encode<Sqlite>>::encode_by_ref(&self.0, buffer)
139 }
140}
141
142impl<'row> Decode<'row, Sqlite> for RecurrenceSeriesId {
143 fn decode(value: SqliteValueRef<'row>) -> Result<Self, BoxDynError> {
144 String::decode(value)?.parse().map_err(Into::into)
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum RecurrenceDuePolicy {
151 SameDay,
152 None,
153}
154
155impl RecurrenceDuePolicy {
156 pub const fn as_str(self) -> &'static str {
157 match self {
158 Self::SameDay => "same_day",
159 Self::None => "none",
160 }
161 }
162
163 pub fn parse(value: &str) -> Result<Self, InvalidRecurrenceValue> {
164 match value {
165 "same_day" => Ok(Self::SameDay),
166 "none" => Ok(Self::None),
167 _ => Err(InvalidRecurrenceValue::new("due policy", value)),
168 }
169 }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum RecurrenceSeriesState {
175 Active,
176 Paused,
177 Stopped,
178}
179
180impl RecurrenceSeriesState {
181 pub const fn as_str(self) -> &'static str {
182 match self {
183 Self::Active => "active",
184 Self::Paused => "paused",
185 Self::Stopped => "stopped",
186 }
187 }
188
189 pub fn parse(value: &str) -> Result<Self, InvalidRecurrenceValue> {
190 match value {
191 "active" => Ok(Self::Active),
192 "paused" => Ok(Self::Paused),
193 "stopped" => Ok(Self::Stopped),
194 _ => Err(InvalidRecurrenceValue::new("series state", value)),
195 }
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum RecurrenceOutcome {
202 Completed,
203 Skipped,
204}
205
206impl RecurrenceOutcome {
207 pub const fn as_str(self) -> &'static str {
208 match self {
209 Self::Completed => "completed",
210 Self::Skipped => "skipped",
211 }
212 }
213
214 pub fn parse(value: &str) -> Result<Self, InvalidRecurrenceValue> {
215 match value {
216 "completed" => Ok(Self::Completed),
217 "skipped" => Ok(Self::Skipped),
218 _ => Err(InvalidRecurrenceValue::new("outcome", value)),
219 }
220 }
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "snake_case")]
225pub enum RecurrenceProjectionState {
226 Projected,
227 Resolved,
228 Archived,
229}
230
231impl RecurrenceProjectionState {
232 pub const fn as_str(self) -> &'static str {
233 match self {
234 Self::Projected => "projected",
235 Self::Resolved => "resolved",
236 Self::Archived => "archived",
237 }
238 }
239
240 pub fn parse(value: &str) -> Result<Self, InvalidRecurrenceValue> {
241 match value {
242 "projected" => Ok(Self::Projected),
243 "resolved" => Ok(Self::Resolved),
244 "archived" => Ok(Self::Archived),
245 _ => Err(InvalidRecurrenceValue::new("projection state", value)),
246 }
247 }
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct InvalidRecurrenceValue {
252 kind: &'static str,
253 value: String,
254}
255
256impl InvalidRecurrenceValue {
257 fn new(kind: &'static str, value: &str) -> Self {
258 Self {
259 kind,
260 value: value.to_string(),
261 }
262 }
263}
264
265impl fmt::Display for InvalidRecurrenceValue {
266 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
267 write!(
268 formatter,
269 "invalid recurrence {}: {}",
270 self.kind, self.value
271 )
272 }
273}
274
275impl std::error::Error for InvalidRecurrenceValue {}
276
277#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
278#[serde(transparent)]
279pub struct TimeZoneId(String);
280
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct InvalidTimeZoneId(String);
283
284impl TimeZoneId {
285 pub fn as_str(&self) -> &str {
286 &self.0
287 }
288
289 pub(crate) fn timezone(&self) -> chrono_tz::Tz {
290 self.0
291 .parse()
292 .expect("TimeZoneId always contains a chrono-tz zone")
293 }
294}
295
296impl fmt::Display for TimeZoneId {
297 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
298 self.0.fmt(formatter)
299 }
300}
301
302impl fmt::Display for InvalidTimeZoneId {
303 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
304 write!(formatter, "invalid IANA time zone: {}", self.0)
305 }
306}
307
308impl std::error::Error for InvalidTimeZoneId {}
309
310impl FromStr for TimeZoneId {
311 type Err = InvalidTimeZoneId;
312
313 fn from_str(value: &str) -> Result<Self, Self::Err> {
314 value
315 .parse::<chrono_tz::Tz>()
316 .map(|_| Self(value.to_string()))
317 .map_err(|_| InvalidTimeZoneId(value.to_string()))
318 }
319}
320
321impl TryFrom<String> for TimeZoneId {
322 type Error = InvalidTimeZoneId;
323
324 fn try_from(value: String) -> Result<Self, Self::Error> {
325 value.parse()
326 }
327}
328
329impl<'de> Deserialize<'de> for TimeZoneId {
330 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
331 where
332 D: Deserializer<'de>,
333 {
334 String::deserialize(deserializer)?
335 .parse()
336 .map_err(serde::de::Error::custom)
337 }
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct RecurrenceSchedule {
342 pub rule: RecurrenceRule,
343 pub timezone: TimeZoneId,
344 pub start_on: NaiveDate,
345 pub available_local_time: Option<NaiveTime>,
346 pub due_policy: RecurrenceDuePolicy,
347}
348
349impl RecurrenceSchedule {
350 pub fn new(
351 rule: RecurrenceRule,
352 timezone: TimeZoneId,
353 start_on: NaiveDate,
354 available_local_time: Option<NaiveTime>,
355 due_policy: RecurrenceDuePolicy,
356 ) -> Self {
357 Self {
358 rule,
359 timezone,
360 start_on,
361 available_local_time,
362 due_policy,
363 }
364 }
365
366 pub fn slots_on_or_after(&self, date: NaiveDate) -> RecurrenceSlotIter<'_> {
367 RecurrenceSlotIter::new(&self.rule, self.start_on, date)
368 }
369}
370
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
372pub struct RecurrenceSlot {
373 pub scheduled_on: NaiveDate,
374 pub boundary_at: String,
375 pub available_at: String,
376 pub due_on: Option<String>,
377}