use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "UPPERCASE")]
pub enum Frequency {
Yearly,
Monthly,
Weekly,
Daily,
Hourly,
Minutely,
Secondly,
}
impl Frequency {
pub fn as_str(&self) -> &'static str {
match self {
Frequency::Yearly => "YEARLY",
Frequency::Monthly => "MONTHLY",
Frequency::Weekly => "WEEKLY",
Frequency::Daily => "DAILY",
Frequency::Hourly => "HOURLY",
Frequency::Minutely => "MINUTELY",
Frequency::Secondly => "SECONDLY",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "UPPERCASE")]
pub enum Weekday {
Mo,
Tu,
We,
Th,
Fr,
Sa,
Su,
}
impl Weekday {
pub fn as_str(&self) -> &'static str {
match self {
Weekday::Mo => "MO",
Weekday::Tu => "TU",
Weekday::We => "WE",
Weekday::Th => "TH",
Weekday::Fr => "FR",
Weekday::Sa => "SA",
Weekday::Su => "SU",
}
}
}
impl FromStr for Weekday {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"MO" => Ok(Weekday::Mo),
"TU" => Ok(Weekday::Tu),
"WE" => Ok(Weekday::We),
"TH" => Ok(Weekday::Th),
"FR" => Ok(Weekday::Fr),
"SA" => Ok(Weekday::Sa),
"SU" => Ok(Weekday::Su),
_ => Err(format!("Invalid weekday: {}", s)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WeekdayNum {
pub weekday: Weekday,
pub n: Option<i32>, }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DateItem {
pub date: String, #[serde(skip_serializing_if = "Option::is_none")]
pub tzid: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecurrenceObject {
pub freq: Frequency,
#[serde(skip_serializing_if = "Option::is_none")]
pub dtstart: Option<String>, #[serde(skip_serializing_if = "Option::is_none")]
pub interval: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub until: Option<String>, #[serde(skip_serializing_if = "Option::is_none")]
pub tzid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wkst: Option<Weekday>, #[serde(skip_serializing_if = "Option::is_none")]
pub byday: Option<Vec<WeekdayNum>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bymonth: Option<Vec<u32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bymonthday: Option<Vec<i32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub byyearday: Option<Vec<i32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub byweekno: Option<Vec<i32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub byhour: Option<Vec<u32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub byminute: Option<Vec<u32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bysecond: Option<Vec<u32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bysetpos: Option<Vec<i32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exdate: Option<Vec<DateItem>>, #[serde(skip_serializing_if = "Option::is_none")]
pub rdate: Option<Vec<DateItem>>, }
pub fn parse_recurrence_to_object(recurrence: &str) -> Result<Option<RecurrenceObject>, String> {
let mut obj: RecurrenceObject = RecurrenceObject {
freq: Frequency::Daily, dtstart: None,
interval: None,
count: None,
until: None,
tzid: None,
wkst: None,
byday: None,
bymonth: None,
bymonthday: None,
byyearday: None,
byweekno: None,
byhour: None,
byminute: None,
bysecond: None,
bysetpos: None,
exdate: None,
rdate: None,
};
let mut found_rrule = false;
for line in recurrence.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if line.starts_with("DTSTART") {
parse_dtstart(line, &mut obj)?;
} else if line.starts_with("RRULE:") {
parse_rrule(&line[6..], &mut obj)?;
found_rrule = true;
} else if is_rrule_content(line) {
parse_rrule(line, &mut obj)?;
found_rrule = true;
} else if line.starts_with("EXDATE") {
parse_exdate(line, &mut obj)?;
} else if line.starts_with("RDATE") {
parse_rdate(line, &mut obj)?;
}
}
if !found_rrule {
return Ok(None);
}
Ok(Some(obj))
}
fn parse_dtstart(line: &str, obj: &mut RecurrenceObject) -> Result<(), String> {
let parts: Vec<&str> = line.split(':').collect();
if parts.len() < 2 {
return Err("Invalid DTSTART format".to_string());
}
let header = parts[0];
let value = parts[1..].join(":");
if header.contains("TZID=") {
if let Some(tzid_part) = header.split(';').find(|p| p.starts_with("TZID=")) {
obj.tzid = Some(tzid_part[5..].to_string());
}
}
if header.contains("VALUE=DATE") {
obj.dtstart = Some(format_date_value(&value)?);
} else {
obj.dtstart = Some(format_datetime_value(&value)?);
}
Ok(())
}
fn parse_rrule(rrule_content: &str, obj: &mut RecurrenceObject) -> Result<(), String> {
let params = parse_params(rrule_content);
if let Some(freq_str) = params.get("FREQ") {
obj.freq = match freq_str.as_str() {
"YEARLY" => Frequency::Yearly,
"MONTHLY" => Frequency::Monthly,
"WEEKLY" => Frequency::Weekly,
"DAILY" => Frequency::Daily,
"HOURLY" => Frequency::Hourly,
"MINUTELY" => Frequency::Minutely,
"SECONDLY" => Frequency::Secondly,
_ => return Err(format!("Invalid FREQ value: {}", freq_str)),
};
} else {
return Err("FREQ is required in RRULE".to_string());
}
if let Some(interval_str) = params.get("INTERVAL") {
obj.interval = Some(interval_str.parse().map_err(|_| "Invalid INTERVAL")?);
}
if let Some(count_str) = params.get("COUNT") {
obj.count = Some(count_str.parse().map_err(|_| "Invalid COUNT")?);
}
if let Some(until_str) = params.get("UNTIL") {
obj.until = Some(format_until_value(until_str)?);
}
if let Some(wkst_str) = params.get("WKST") {
obj.wkst = Some(parse_weekday(wkst_str)?);
}
if let Some(byday_str) = params.get("BYDAY") {
obj.byday = Some(parse_byday(byday_str)?);
}
if let Some(bymonth_str) = params.get("BYMONTH") {
obj.bymonth = Some(parse_int_list(bymonth_str)?);
}
if let Some(bymonthday_str) = params.get("BYMONTHDAY") {
obj.bymonthday = Some(parse_signed_int_list(bymonthday_str)?);
}
if let Some(byyearday_str) = params.get("BYYEARDAY") {
obj.byyearday = Some(parse_signed_int_list(byyearday_str)?);
}
if let Some(byweekno_str) = params.get("BYWEEKNO") {
obj.byweekno = Some(parse_signed_int_list(byweekno_str)?);
}
if let Some(byhour_str) = params.get("BYHOUR") {
obj.byhour = Some(parse_int_list(byhour_str)?);
}
if let Some(byminute_str) = params.get("BYMINUTE") {
obj.byminute = Some(parse_int_list(byminute_str)?);
}
if let Some(bysecond_str) = params.get("BYSECOND") {
obj.bysecond = Some(parse_int_list(bysecond_str)?);
}
if let Some(bysetpos_str) = params.get("BYSETPOS") {
obj.bysetpos = Some(parse_signed_int_list(bysetpos_str)?);
}
Ok(())
}
fn parse_exdate(line: &str, obj: &mut RecurrenceObject) -> Result<(), String> {
let parts: Vec<&str> = line.split(':').collect();
if parts.len() < 2 {
return Err("Invalid EXDATE format".to_string());
}
let header = parts[0];
let values = parts[1..].join(":");
let mut tzid = None;
if header.contains("TZID=") {
if let Some(tzid_part) = header.split(';').find(|p| p.starts_with("TZID=")) {
tzid = Some(tzid_part[5..].to_string());
}
}
let is_date_only = header.contains("VALUE=DATE");
let dates: Vec<DateItem> = values
.split(',')
.map(|v| {
let v = v.trim();
let date = if is_date_only {
format_date_value(v)?
} else {
format_datetime_value(v)?
};
Ok(DateItem {
date,
tzid: tzid.clone(),
})
})
.collect::<Result<Vec<DateItem>, String>>()?;
if let Some(ref mut exdate) = obj.exdate {
exdate.extend(dates);
} else {
obj.exdate = Some(dates);
}
Ok(())
}
fn parse_rdate(line: &str, obj: &mut RecurrenceObject) -> Result<(), String> {
let parts: Vec<&str> = line.split(':').collect();
if parts.len() < 2 {
return Err("Invalid RDATE format".to_string());
}
let header = parts[0];
let values = parts[1..].join(":");
let mut tzid = None;
if header.contains("TZID=") {
if let Some(tzid_part) = header.split(';').find(|p| p.starts_with("TZID=")) {
tzid = Some(tzid_part[5..].to_string());
}
}
let is_date_only = header.contains("VALUE=DATE");
let dates: Vec<DateItem> = values
.split(',')
.map(|v| {
let v = v.trim();
let date = if is_date_only {
format_date_value(v)?
} else {
format_datetime_value(v)?
};
Ok(DateItem {
date,
tzid: tzid.clone(),
})
})
.collect::<Result<Vec<DateItem>, String>>()?;
if let Some(ref mut rdate) = obj.rdate {
rdate.extend(dates);
} else {
obj.rdate = Some(dates);
}
Ok(())
}
fn parse_params(param_str: &str) -> HashMap<String, String> {
let mut params = HashMap::new();
for part in param_str.split(';') {
if let Some(eq_pos) = part.find('=') {
let key = part[..eq_pos].to_string();
let value = part[eq_pos + 1..].to_string();
params.insert(key, value);
}
}
params
}
fn is_rrule_content(s: &str) -> bool {
if !s.contains("FREQ=") {
return false;
}
if s.contains(':') {
return false;
}
if let Some(first) = s.split(';').next() {
return first.starts_with("FREQ=");
}
false
}
pub fn parse_weekday(s: &str) -> Result<Weekday, String> {
Weekday::from_str(s)
}
fn parse_byday(s: &str) -> Result<Vec<WeekdayNum>, String> {
s.split(',')
.map(|day| {
let day = day.trim();
let mut n = None;
let weekday_str = if day.len() > 2 {
let (prefix, wd) = day.split_at(day.len() - 2);
if let Ok(num) = prefix.parse::<i32>() {
n = Some(num);
}
wd
} else {
day
};
Ok(WeekdayNum {
weekday: parse_weekday(weekday_str)?,
n,
})
})
.collect()
}
fn parse_int_list<T: std::str::FromStr>(s: &str) -> Result<Vec<T>, String> {
s.split(',')
.map(|v| {
v.trim()
.parse()
.map_err(|_| format!("Invalid integer: {}", v))
})
.collect()
}
fn parse_signed_int_list(s: &str) -> Result<Vec<i32>, String> {
parse_int_list(s)
}
fn format_date_value(s: &str) -> Result<String, String> {
if s.len() != 8 {
return Err(format!("Invalid date format: {}", s));
}
Ok(format!("{}-{}-{}", &s[0..4], &s[4..6], &s[6..8]))
}
fn format_datetime_value(s: &str) -> Result<String, String> {
let s = s.trim();
if s.len() < 15 {
return Err(format!("Invalid datetime format: {}", s));
}
let has_z = s.ends_with('Z');
let base = if has_z { &s[..s.len() - 1] } else { s };
if base.len() != 15 || base.chars().nth(8) != Some('T') {
return Err(format!("Invalid datetime format: {}", s));
}
let formatted = format!(
"{}-{}-{}T{}:{}:{}",
&base[0..4],
&base[4..6],
&base[6..8],
&base[9..11],
&base[11..13],
&base[13..15]
);
Ok(if has_z {
format!("{}Z", formatted)
} else {
formatted
})
}
fn format_until_value(s: &str) -> Result<String, String> {
if s.len() == 8 {
format_date_value(s)
} else {
format_datetime_value(s)
}
}
pub fn recurrence_object_to_string(obj: &RecurrenceObject) -> String {
let mut lines: Vec<String> = Vec::new();
if let Some(dtstart) = &obj.dtstart {
let dtstart_ical = to_ical_value(dtstart);
if let Some(tzid) = &obj.tzid {
lines.push(format!("DTSTART;TZID={}:{}", tzid, dtstart_ical));
} else {
lines.push(format!("DTSTART:{}", dtstart_ical));
}
}
let mut parts: Vec<String> = Vec::new();
parts.push(format!("FREQ={}", obj.freq.as_str()));
if let Some(interval) = obj.interval {
parts.push(format!("INTERVAL={}", interval));
}
if let Some(count) = obj.count {
parts.push(format!("COUNT={}", count));
}
if let Some(until) = &obj.until {
parts.push(format!("UNTIL={}", to_ical_value(until)));
}
if let Some(wkst) = &obj.wkst {
parts.push(format!("WKST={}", wkst.as_str()));
}
if let Some(bysecond) = &obj.bysecond {
if !bysecond.is_empty() {
parts.push(format!("BYSECOND={}", join_u32(bysecond)));
}
}
if let Some(byminute) = &obj.byminute {
if !byminute.is_empty() {
parts.push(format!("BYMINUTE={}", join_u32(byminute)));
}
}
if let Some(byhour) = &obj.byhour {
if !byhour.is_empty() {
parts.push(format!("BYHOUR={}", join_u32(byhour)));
}
}
if let Some(byday) = &obj.byday {
if !byday.is_empty() {
parts.push(format!("BYDAY={}", join_byday(byday)));
}
}
if let Some(bymonthday) = &obj.bymonthday {
if !bymonthday.is_empty() {
parts.push(format!("BYMONTHDAY={}", join_i32(bymonthday)));
}
}
if let Some(byyearday) = &obj.byyearday {
if !byyearday.is_empty() {
parts.push(format!("BYYEARDAY={}", join_i32(byyearday)));
}
}
if let Some(byweekno) = &obj.byweekno {
if !byweekno.is_empty() {
parts.push(format!("BYWEEKNO={}", join_i32(byweekno)));
}
}
if let Some(bymonth) = &obj.bymonth {
if !bymonth.is_empty() {
parts.push(format!("BYMONTH={}", join_u32(bymonth)));
}
}
if let Some(bysetpos) = &obj.bysetpos {
if !bysetpos.is_empty() {
parts.push(format!("BYSETPOS={}", join_i32(bysetpos)));
}
}
lines.push(format!("RRULE:{}", parts.join(";")));
if let Some(exdates) = &obj.exdate {
lines.extend(build_date_lines("EXDATE", exdates));
}
if let Some(rdates) = &obj.rdate {
lines.extend(build_date_lines("RDATE", rdates));
}
lines.join("\n")
}
fn to_ical_value(value: &str) -> String {
if !value.contains('-') {
return value.to_string();
}
if !value.contains('T') {
return value.replace('-', "");
}
let mut v = value.to_string();
let is_utc = v.ends_with('Z');
if is_utc {
v.pop();
}
if let Some(dot) = v.find('.') {
v.truncate(dot);
}
let (date, time) = match v.split_once('T') {
Some(x) => x,
None => return value.replace(['-', ':'], ""),
};
let date_compact = date.replace('-', "");
let time_compact = time.replace(':', "");
let out = format!("{}T{}", date_compact, time_compact);
if is_utc { format!("{}Z", out) } else { out }
}
fn join_u32(list: &[u32]) -> String {
list.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(",")
}
fn join_i32(list: &[i32]) -> String {
list.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(",")
}
fn join_byday(list: &[WeekdayNum]) -> String {
list.iter()
.map(|d| match d.n {
Some(n) => format!("{}{}", n, d.weekday.as_str()),
None => d.weekday.as_str().to_string(),
})
.collect::<Vec<_>>()
.join(",")
}
fn build_date_lines(kind: &str, items: &[DateItem]) -> Vec<String> {
let mut map: HashMap<Option<String>, Vec<String>> = HashMap::new();
for it in items {
map.entry(it.tzid.clone())
.or_insert_with(Vec::new)
.push(to_ical_value(&it.date));
}
let mut keys: Vec<Option<String>> = map.keys().cloned().collect();
keys.sort_by(|a, b| match (a, b) {
(None, None) => std::cmp::Ordering::Equal,
(None, Some(_)) => std::cmp::Ordering::Less,
(Some(_), None) => std::cmp::Ordering::Greater,
(Some(aa), Some(bb)) => aa.cmp(bb),
});
let mut out = Vec::new();
for key in keys {
let dates = match map.get(&key) {
Some(d) if !d.is_empty() => d,
_ => continue,
};
let joined = dates.join(",");
match &key {
Some(tzid) => out.push(format!("{};TZID={}:{}", kind, tzid, joined)),
None => out.push(format!("{}:{}", kind, joined)),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_daily() {
let recurrence = "RRULE:FREQ=DAILY;COUNT=5";
let obj = parse_recurrence_to_object(recurrence).unwrap().unwrap();
assert!(matches!(obj.freq, Frequency::Daily));
assert_eq!(obj.count, Some(5));
assert_eq!(obj.interval, None); }
#[test]
fn test_parse_weekly_with_byday() {
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=10";
let obj = parse_recurrence_to_object(recurrence).unwrap().unwrap();
assert!(matches!(obj.freq, Frequency::Weekly));
assert_eq!(obj.count, Some(10));
assert_eq!(obj.byday.as_ref().unwrap().len(), 3);
}
#[test]
fn test_parse_with_dtstart_and_tzid() {
let recurrence =
"DTSTART;TZID=America/New_York:20250101T100000\nRRULE:FREQ=WEEKLY;COUNT=10";
let obj = parse_recurrence_to_object(recurrence).unwrap().unwrap();
assert_eq!(obj.tzid, Some("America/New_York".to_string()));
assert_eq!(obj.dtstart, Some("2025-01-01T10:00:00".to_string()));
assert_eq!(obj.count, Some(10));
}
#[test]
fn test_parse_with_until() {
let recurrence = "RRULE:FREQ=DAILY;UNTIL=20251231T235959Z";
let obj = parse_recurrence_to_object(recurrence).unwrap().unwrap();
assert_eq!(obj.until, Some("2025-12-31T23:59:59Z".to_string()));
}
#[test]
fn test_parse_with_exdate() {
let recurrence = "RRULE:FREQ=DAILY;COUNT=10\nEXDATE:20250105T100000Z,20250106T100000Z";
let obj = parse_recurrence_to_object(recurrence).unwrap().unwrap();
let exdates = obj.exdate.unwrap();
assert_eq!(exdates.len(), 2);
assert!(exdates.contains(&DateItem {
date: "2025-01-05T10:00:00Z".to_string(),
tzid: None
}));
}
#[test]
fn test_parse_date_only() {
let recurrence = "DTSTART;VALUE=DATE:20250101\nRRULE:FREQ=WEEKLY;BYDAY=MO";
let obj = parse_recurrence_to_object(recurrence).unwrap().unwrap();
assert_eq!(obj.dtstart, Some("2025-01-01".to_string()));
}
#[test]
fn test_parse_monthly_with_bymonthday() {
let recurrence = "RRULE:FREQ=MONTHLY;BYMONTHDAY=1,15,-1";
let obj = parse_recurrence_to_object(recurrence).unwrap().unwrap();
let bymonthday = obj.bymonthday.unwrap();
assert_eq!(bymonthday, vec![1, 15, -1]);
}
#[test]
fn test_parse_with_interval() {
let recurrence = "RRULE:FREQ=DAILY;INTERVAL=2;COUNT=10";
let obj = parse_recurrence_to_object(recurrence).unwrap().unwrap();
assert_eq!(obj.interval, Some(2));
}
#[test]
fn test_parse_complex_byday() {
let recurrence = "RRULE:FREQ=MONTHLY;BYDAY=1MO,-1FR";
let obj = parse_recurrence_to_object(recurrence).unwrap().unwrap();
let byday = obj.byday.unwrap();
assert_eq!(byday.len(), 2);
assert_eq!(byday[0].n, Some(1));
assert_eq!(byday[1].n, Some(-1));
}
#[test]
fn test_error_no_rrule() {
let recurrence = "DTSTART:20250101T100000Z";
let result = parse_recurrence_to_object(recurrence);
assert!(result.unwrap().is_none());
}
#[test]
fn test_error_invalid_freq() {
let recurrence = "RRULE:FREQ=INVALID";
let result = parse_recurrence_to_object(recurrence);
assert!(result.is_err());
}
#[test]
fn test_parse_without_rrule_prefix() {
let recurrence = "FREQ=DAILY;COUNT=3";
let obj = parse_recurrence_to_object(recurrence).unwrap().unwrap();
assert!(matches!(obj.freq, Frequency::Daily));
assert_eq!(obj.count, Some(3));
}
#[test]
fn test_ignore_prefixed_freq_line() {
let recurrence = "PREFIX;FREQ=DAILY;COUNT=3";
let result = parse_recurrence_to_object(recurrence);
assert!(result.unwrap().is_none());
}
#[test]
fn test_ignore_line_with_colon_and_freq() {
let recurrence = "EXAMPLE:VALUE=1;FREQ=DAILY";
let result = parse_recurrence_to_object(recurrence);
assert!(result.unwrap().is_none());
}
#[test]
fn test_parse_with_exdate_tzid() {
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=SA\nEXDATE;TZID=Europe/Bucharest:20251212T083000";
let obj = parse_recurrence_to_object(recurrence).unwrap().unwrap();
let exdates = obj.exdate.unwrap();
assert_eq!(exdates.len(), 1);
assert_eq!(
exdates[0],
DateItem {
date: "2025-12-12T08:30:00".to_string(),
tzid: Some("Europe/Bucharest".to_string())
}
);
}
#[test]
fn test_object_to_string_roundtrip_simple() {
let recurrence = "DTSTART;TZID=Europe/Bucharest:20251212T083000\nRRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=5\nEXDATE;TZID=Europe/Bucharest:20251226T083000";
let obj = parse_recurrence_to_object(recurrence).unwrap().unwrap();
let out = recurrence_object_to_string(&obj);
assert!(out.contains("DTSTART;TZID=Europe/Bucharest:20251212T083000"));
assert!(
out.contains("RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=5;BYDAY=MO,WE")
|| out.contains("RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=5;BYDAY=MO,WE")
);
assert!(out.contains("EXDATE;TZID=Europe/Bucharest:20251226T083000"));
}
#[test]
fn test_object_to_string_groups_exdate_by_tzid() {
let obj = RecurrenceObject {
freq: Frequency::Daily,
dtstart: None,
interval: None,
count: Some(2),
until: None,
tzid: None,
wkst: None,
byday: None,
bymonth: None,
bymonthday: None,
byyearday: None,
byweekno: None,
byhour: None,
byminute: None,
bysecond: None,
bysetpos: None,
exdate: Some(vec![
DateItem {
date: "20251212T083000".to_string(),
tzid: Some("Europe/Bucharest".to_string()),
},
DateItem {
date: "20251213T083000".to_string(),
tzid: Some("Europe/Bucharest".to_string()),
},
DateItem {
date: "20251214T083000".to_string(),
tzid: None,
},
]),
rdate: None,
};
let out = recurrence_object_to_string(&obj);
assert!(out.contains("EXDATE;TZID=Europe/Bucharest:20251212T083000,20251213T083000"));
assert!(out.contains("EXDATE:20251214T083000"));
}
}