//! Changing the bounds of an `RRULE` without rewriting the rest of it. //! //! Done as a string operation on purpose. Parsing the rule into `rrule`'s type //! and rendering it back would work, but it normalises: part order changes, //! defaulted parts appear, and `WKST=SU` written by Exchange may or may not //! survive. Since the model stores the rule exactly as the producer wrote it, //! the edit should touch exactly the part being edited and leave every other //! byte alone. use chrono::{DateTime, Utc}; use thiserror::Error; /// Replaces a rule's bound with `UNTIL`. /// /// `COUNT` and `UNTIL` are mutually exclusive (RFC 5545 ยง3.3.10), so any /// existing bound of either kind is removed first. A rule that said "ten times" /// cannot also say "until March". pub fn set_until(rrule: &str, until: DateTime) -> Result { let mut parts = keep_unbounded_parts(rrule)?; parts.push(format!("UNTIL={}", until.format("%Y%m%dT%H%M%SZ"))); Ok(parts.join(";")) } /// Removes `COUNT` and `UNTIL`, leaving an unbounded rule. /// /// Used when a series is split: the second half inherits the pattern but not /// the bound, because "ten occurrences" counted from the original start and /// means nothing measured from half way along. pub fn strip_bounds(rrule: &str) -> String { keep_unbounded_parts(rrule) .map(|parts| parts.join(";")) .unwrap_or_else(|_| rrule.to_owned()) } /// The `COUNT` a rule carries, if it is bounded that way. pub fn count_of(rrule: &str) -> Option { rrule .split(';') .filter_map(|part| part.trim().split_once('=')) .find(|(name, _)| name.eq_ignore_ascii_case("COUNT")) .and_then(|(_, value)| value.trim().parse().ok()) } /// Replaces a rule's bound with `COUNT`. /// /// Used when a counted series is split: "six times" was counted from the /// original start, so the second half carries however many are left rather /// than becoming unbounded. Dropping the bound would quietly turn a finite /// commitment into a permanent one. pub fn set_count(rrule: &str, count: u32) -> Result { let mut parts = keep_unbounded_parts(rrule)?; parts.push(format!("COUNT={count}")); Ok(parts.join(";")) } fn keep_unbounded_parts(rrule: &str) -> Result, RuleError> { let trimmed = rrule.trim(); if trimmed.is_empty() { return Err(RuleError::Empty); } let parts: Vec = trimmed .split(';') .map(str::trim) .filter(|part| !part.is_empty()) .filter(|part| { let name = part.split('=').next().unwrap_or_default(); !name.eq_ignore_ascii_case("UNTIL") && !name.eq_ignore_ascii_case("COUNT") }) .map(str::to_owned) .collect(); if !parts.iter().any(|part| { part.split('=') .next() .unwrap_or_default() .eq_ignore_ascii_case("FREQ") }) { return Err(RuleError::NoFrequency(trimmed.to_owned())); } Ok(parts) } #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RuleError { #[error("the recurrence rule is empty")] Empty, /// Every `RRULE` must have a `FREQ`; without one there is no pattern and /// bounding it would produce something meaningless. #[error("the recurrence rule {0:?} has no FREQ")] NoFrequency(String), }