///|
pub fn RRule::RRule(
freq : Frequency,
interval? : Int = 1,
count? : Int,
until? : DateTime,
byday? : Array[Weekday] = [],
bymonthday? : Array[Int] = [],
bymonth? : Array[Int] = [],
) -> RRule {
RRule::{ freq, interval, count, until, byday, bymonthday, bymonth }
}
///|
pub fn frequency_to_string(freq : Frequency) -> String {
match freq {
Daily => "DAILY"
Weekly => "WEEKLY"
Monthly => "MONTHLY"
Yearly => "YEARLY"
}
}
///|
pub fn weekday_to_string(day : Weekday) -> String {
match day {
MO => "MO"
TU => "TU"
WE => "WE"
TH => "TH"
FR => "FR"
SA => "SA"
SU => "SU"
}
}
///|
pub fn parse_weekday(value : String) -> Weekday? {
match value.to_upper() {
"MO" => Some(MO)
"TU" => Some(TU)
"WE" => Some(WE)
"TH" => Some(TH)
"FR" => Some(FR)
"SA" => Some(SA)
"SU" => Some(SU)
_ => None
}
}
///|
pub fn parse_frequency(value : String) -> Frequency? {
match value.to_upper() {
"DAILY" => Some(Daily)
"WEEKLY" => Some(Weekly)
"MONTHLY" => Some(Monthly)
"YEARLY" => Some(Yearly)
_ => None
}
}
///|
pub fn parse_rrule(input : String) -> Result[RRule, MoonCalError] {
let raw = input.trim().to_owned()
if raw.is_empty() {
return Err(InvalidRRule(value=raw, message="RRULE is empty"))
}
let mut freq : Frequency? = None
let mut interval = 1
let mut count : Int? = None
let mut until : DateTime? = None
let byday : Array[Weekday] = []
let bymonthday : Array[Int] = []
let bymonth : Array[Int] = []
for part_view in raw.split(";").to_array() {
let part = part_view.to_owned().trim().to_owned()
if part.is_empty() {
return Err(InvalidRRule(value=raw, message="empty RRULE part"))
}
match part.split_once("=") {
Some((key_view, value_view)) => {
let key = key_view.to_owned().trim().to_upper()
let value = value_view.to_owned().trim().to_owned()
if key == "FREQ" {
match parse_frequency(value) {
Some(parsed) => freq = Some(parsed)
None =>
return Err(
InvalidRRule(value=raw, message="unsupported FREQ value"),
)
}
} else if key == "INTERVAL" {
match parse_positive_int(value) {
Some(n) if n > 0 => interval = n
_ =>
return Err(
InvalidRRule(value=raw, message="INTERVAL must be positive"),
)
}
} else if key == "COUNT" {
match parse_positive_int(value) {
Some(n) if n > 0 => count = Some(n)
_ =>
return Err(
InvalidRRule(value=raw, message="COUNT must be positive"),
)
}
} else if key == "UNTIL" {
match parse_datetime(value) {
Ok(dt) => until = Some(dt)
Err(_) =>
return Err(
InvalidRRule(
value=raw,
message="UNTIL must be DATE-TIME or DATE",
),
)
}
} else if key == "BYDAY" {
match parse_byday_list(value) {
Ok(days) => byday.append(days)
Err(err) => return Err(err)
}
} else if key == "BYMONTHDAY" {
match
parse_int_list(
value,
min=-31,
max=31,
context="BYMONTHDAY",
source=raw,
) {
Ok(values) => bymonthday.append(values)
Err(err) => return Err(err)
}
} else if key == "BYMONTH" {
match
parse_int_list(value, min=1, max=12, context="BYMONTH", source=raw) {
Ok(values) => bymonth.append(values)
Err(err) => return Err(err)
}
} else {
return Err(
InvalidRRule(value=raw, message="unsupported RRULE part: \{key}"),
)
}
}
None =>
return Err(
InvalidRRule(value=raw, message="RRULE part must contain '='"),
)
}
}
match freq {
Some(freq) =>
Ok(RRule::{ freq, interval, count, until, byday, bymonthday, bymonth })
None => Err(InvalidRRule(value=raw, message="FREQ is required"))
}
}
///|
fn parse_byday_list(value : String) -> Result[Array[Weekday], MoonCalError] {
let days : Array[Weekday] = []
for item_view in value.split(",").to_array() {
let item = item_view.to_owned().trim().to_owned()
if item.is_empty() {
return Err(InvalidRRule(value~, message="BYDAY contains an empty item"))
}
match parse_weekday(item) {
Some(day) => days.push(day)
None =>
return Err(
InvalidRRule(value~, message="BYDAY supports MO,TU,WE,TH,FR,SA,SU"),
)
}
}
Ok(days)
}
///|
fn parse_int_list(
value : String,
min~ : Int,
max~ : Int,
context~ : String,
source~ : String,
) -> Result[Array[Int], MoonCalError] {
let values : Array[Int] = []
for item_view in value.split(",").to_array() {
let item = item_view.to_owned().trim().to_owned()
match parse_signed_int(item) {
Some(n) if n >= min && n <= max && n != 0 => values.push(n)
_ =>
return Err(
InvalidRRule(
value=source,
message="\{context} contains an out-of-range value",
),
)
}
}
Ok(values)
}
///|
fn parse_positive_int(value : String) -> Int? {
let raw = value.trim().to_owned()
if raw.is_empty() {
return None
}
let mut result = 0
for c in raw {
if c < '0' || c > '9' {
return None
}
result = result * 10 + (c.to_int() - '0'.to_int())
}
Some(result)
}
///|
fn parse_signed_int(value : String) -> Int? {
let raw = value.trim().to_owned()
if raw.is_empty() {
return None
}
let negative = raw.has_prefix("-")
let body = if negative { raw[1:].to_owned() } else { raw }
match parse_positive_int(body) {
Some(n) => if negative { Some(0 - n) } else { Some(n) }
None => None
}
}
///|
fn contains_weekday(days : Array[Weekday], day : Weekday) -> Bool {
for item in days {
if item == day {
return true
}
}
false
}
///|
fn contains_int(values : Array[Int], needle : Int) -> Bool {
for value in values {
if value == needle {
return true
}
}
false
}
///|
fn matches_month_day_filter(values : Array[Int], dt : DateTime) -> Bool {
if values.is_empty() {
return true
}
let dim = days_in_month(dt.year, dt.month)
for value in values {
if value > 0 && value == dt.day {
return true
}
if value < 0 && dim + value + 1 == dt.day {
return true
}
}
false
}
///|
fn rrule_matches_start(
rule : RRule,
start : DateTime,
candidate : DateTime,
) -> Bool {
if candidate.before(start) {
return false
}
if !rule.bymonth.is_empty() && !contains_int(rule.bymonth, candidate.month) {
return false
}
match rule.freq {
Daily => {
let diff = start.days_until(candidate)
diff >= 0 &&
diff % rule.interval == 0 &&
matches_month_day_filter(rule.bymonthday, candidate)
}
Weekly => {
let diff = start.days_until(candidate)
let week_ok = diff >= 0 && diff / 7 % rule.interval == 0
let weekday_ok = if rule.byday.is_empty() {
candidate.weekday() == start.weekday()
} else {
contains_weekday(rule.byday, candidate.weekday())
}
week_ok &&
weekday_ok &&
matches_month_day_filter(rule.bymonthday, candidate)
}
Monthly => {
let diff = start.months_until(candidate)
let day_ok = if rule.bymonthday.is_empty() {
candidate.day == start.day
} else {
matches_month_day_filter(rule.bymonthday, candidate)
}
diff >= 0 && diff % rule.interval == 0 && day_ok
}
Yearly => {
let diff = start.years_until(candidate)
let month_ok = if rule.bymonth.is_empty() {
candidate.month == start.month
} else {
contains_int(rule.bymonth, candidate.month)
}
let day_ok = if rule.bymonthday.is_empty() {
candidate.day == start.day
} else {
matches_month_day_filter(rule.bymonthday, candidate)
}
diff >= 0 && diff % rule.interval == 0 && month_ok && day_ok
}
}
}