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