use serde::{Deserialize, Serialize};
use crate::parse_rrule::{Frequency, RecurrenceObject, Weekday, WeekdayNum};
#[derive(Debug, Serialize, Deserialize)]
pub struct HumanParseResult {
pub recurrence: Option<String>,
pub parsed_text: Option<String>,
}
pub fn human_text_to_recurrence(input: &str) -> HumanParseResult {
let norm = normalize(input);
let count = extract_count(&norm);
let until = match extract_until(&norm) {
Ok(u) => u,
Err(_) => {
return HumanParseResult {
recurrence: None,
parsed_text: None,
};
}
};
let (byhour, byminute) = match extract_time(&norm) {
Ok(t) => t,
Err(_) => {
return HumanParseResult {
recurrence: None,
parsed_text: None,
};
}
};
let (freq, interval, residual) = match parse_frequency_and_interval(&norm) {
Ok(v) => v,
Err(_) => {
return HumanParseResult {
recurrence: None,
parsed_text: None,
};
}
};
let s = residual;
let mut obj = RecurrenceObject {
freq,
dtstart: None,
interval: if interval != 1 { Some(interval) } else { None },
count,
until,
tzid: None,
wkst: None,
byday: None,
bymonth: None,
bymonthday: None,
byyearday: None,
byweekno: None,
byhour: byhour.map(|h| vec![h]),
byminute: byminute.map(|m| vec![m]),
bysecond: None,
bysetpos: None,
rdate: None,
exdate: None,
};
if parse_pattern_details(&s, &mut obj).is_err() {
return HumanParseResult {
recurrence: None,
parsed_text: None,
};
}
HumanParseResult {
recurrence: Some(build_rrule(&obj)),
parsed_text: extract_recurrence_segment(&norm),
}
}
fn normalize(input: &str) -> String {
input
.trim()
.to_lowercase()
.replace('\u{2011}', "-") .replace('\u{2013}', "-") .replace('\u{2014}', "-") .replace(',', " ")
.replace('.', " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn extract_recurrence_segment(s: &str) -> Option<String> {
let toks: Vec<&str> = s.split_whitespace().collect();
fn is_freq_word(t: &str) -> bool {
matches!(
t,
"daily"
| "weekly"
| "monthly"
| "yearly"
| "annually"
| "hourly"
| "minutely"
| "secondly"
)
}
fn is_unit(t: &str) -> bool {
unit_to_freq(t).is_ok()
}
let mut start: Option<usize> = None;
for (i, &tok) in toks.iter().enumerate() {
if tok == "every"
|| tok == "each"
|| is_freq_word(tok)
|| parse_weekday_name(tok).is_some()
|| parse_month_name(tok).is_some()
|| is_unit(tok)
{
start = Some(i);
break;
}
}
let i0 = start?;
let mut j = i0;
while j < toks.len() {
let tok = toks[j];
fn token_ok(tok: &str, idx: usize, toks: &Vec<&str>) -> bool {
if tok == "every"
|| tok == "each"
|| tok == "the"
|| tok == "last"
|| tok == "to"
|| tok == "and"
|| tok == "of"
|| tok == "on"
|| tok == "at"
|| tok == "in"
|| tok == "weekday"
|| tok == "weekdays"
|| tok == "weekend"
|| tok == "weekends"
|| tok == "time"
|| tok == "times"
|| tok == "occurrence"
|| tok == "occurrences"
{
};
if tok == "until" {
if idx + 1 >= toks.len() {
return false;
}
let after = toks[idx + 1..].join(" ");
return capture_iso_date(&after).is_some()
|| capture_month_name_date(&after).is_some();
}
if tok == "for" {
if idx + 2 < toks.len() && toks[idx + 1].chars().all(|c| c.is_ascii_digit()) {
let maybe = toks[idx + 2];
return maybe.starts_with("time") || maybe.starts_with("occurrence");
}
return false;
}
if tok == "at" {
if idx + 1 >= toks.len() {
return false;
}
return parse_hhmm(toks[idx + 1]).is_some();
}
if tok == "on" {
let mut look = idx + 1;
if look >= toks.len() {
return false;
}
if toks[look] == "the" {
look += 1;
if look >= toks.len() {
return false;
}
}
let next = toks[look];
return parse_month_name(next).is_some()
|| parse_ordinal_or_int(next).is_some()
|| parse_weekday_name(next).is_some();
}
match tok {
"every" | "each" | "the" | "last" | "to" | "and" | "of" | "weekday"
| "weekdays" | "weekend" | "weekends" | "time" | "times" | "occurrence"
| "occurrences" | "hourly" | "daily" | "weekly" | "monthly" | "yearly"
| "annually" | "minutely" | "secondly" => true,
_ => {
if tok.contains(":") && parse_hhmm(tok).is_some() {
return true;
}
if tok.len() == 10
&& tok.chars().nth(4) == Some('-')
&& tok.chars().nth(7) == Some('-')
{
return true;
}
tok.chars().all(|c| c.is_ascii_digit())
}
}
}
let ok = token_ok(tok, j, &toks)
|| is_freq_word(tok)
|| is_unit(tok)
|| parse_weekday_name(tok).is_some()
|| parse_month_name(tok).is_some()
|| parse_ordinal_or_int(tok).is_some()
|| parse_nth_token(tok).is_some()
|| (tok.contains(":") && parse_hhmm(tok).is_some())
|| (tok.len() == 10
&& tok.chars().nth(4) == Some('-')
&& tok.chars().nth(7) == Some('-'))
|| tok.chars().all(|c| c.is_ascii_digit());
if !ok {
break;
}
j += 1;
}
let seg = toks[i0..j].join(" ").trim().to_string();
if seg.is_empty() { None } else { Some(seg) }
}
fn parse_frequency_and_interval(s: &str) -> Result<(Frequency, u32, String), String> {
if let Some(unit) = captures_unit_after_phrase(s, "every other") {
return Ok((unit_to_freq(&unit)?, 2, strip_phrases(s, &["every other"])));
}
if let Some(unit) =
captures_unit_after_phrase(s, "every").or_else(|| captures_unit_after_phrase(s, "each"))
{
if parse_month_name(&unit).is_some() {
return Ok((Frequency::Yearly, 1, s.to_string()));
}
}
let toks: Vec<&str> = s.split_whitespace().collect();
if (toks.contains(&"weekend") || toks.contains(&"weekends"))
&& (toks.contains(&"every") || toks.contains(&"each"))
{
return Ok((Frequency::Weekly, 1, s.to_string()));
}
if toks.iter().any(|t| *t == "biweekly" || *t == "fortnightly") {
let residual = toks
.into_iter()
.filter(|t| *t != "biweekly" && *t != "fortnightly")
.collect::<Vec<_>>()
.join(" ");
return Ok((Frequency::Weekly, 2, residual));
}
for (word, freq) in [
("daily", Frequency::Daily),
("weekly", Frequency::Weekly),
("monthly", Frequency::Monthly),
("yearly", Frequency::Yearly),
("annually", Frequency::Yearly),
("hourly", Frequency::Hourly),
("minutely", Frequency::Minutely),
("secondly", Frequency::Secondly),
] {
if s.contains(word) {
return Ok((freq, 1, s.replace(word, "").trim().to_string()));
}
}
if toks.iter().any(|t| *t == "weekday" || *t == "weekdays") {
return Ok((Frequency::Weekly, 1, s.to_string()));
}
if s.contains("every ") || s.contains("each ") {
if let Some((n, unit)) = capture_every_n_unit(s) {
return Ok((unit_to_freq(&unit)?, n, strip_every_n_unit(s, n, &unit)));
}
if let Some(unit) = capture_every_unit(s) {
return Ok((unit_to_freq(&unit)?, 1, strip_unit_only(s, &unit)));
}
for wi in 0..toks.len() {
if parse_weekday_name(toks[wi]).is_none() {
continue;
}
let has_every_each_before = toks[..wi].iter().any(|t| *t == "every" || *t == "each");
if !has_every_each_before {
continue;
}
let is_last = toks.get(wi + 1) == Some(&"last")
|| (toks.get(wi + 1) == Some(&"the") && toks.get(wi + 2) == Some(&"last"));
if is_last {
return Ok((Frequency::Monthly, 1, s.to_string()));
}
}
if let Some(_day) = capture_single_weekday_after_every(s) {
return Ok((Frequency::Weekly, 1, s.to_string()));
}
}
if (s.contains("last day") || s.contains("day")) && s.contains("month") && s.contains("every") {
return Ok((Frequency::Monthly, 1, s.to_string()));
}
Err("Could not determine frequency from human text".to_string())
}
fn parse_pattern_details(s: &str, obj: &mut RecurrenceObject) -> Result<(), String> {
if obj.freq == Frequency::Yearly {
if let Some((month, day)) = capture_month_and_monthday(s) {
obj.bymonth = Some(vec![month]);
obj.bymonthday = Some(vec![day]);
return Ok(());
}
}
if obj.freq == Frequency::Monthly {
if s.contains("last day of the month") || s.contains("on the last day") {
obj.bymonthday = Some(vec![-1]);
return Ok(());
}
if let Some(wdn) = capture_nth_weekday(s)? {
obj.byday = Some(vec![wdn]);
return Ok(());
}
if let Some(days) = capture_monthdays_list(s) {
obj.bymonthday = Some(days);
return Ok(());
}
let toks: Vec<&str> = s.split_whitespace().collect();
let has_weekday = toks.iter().any(|t| *t == "weekday" || *t == "weekdays");
if has_weekday {
obj.byday = Some(vec![
WeekdayNum {
n: None,
weekday: Weekday::Mo,
},
WeekdayNum {
n: None,
weekday: Weekday::Tu,
},
WeekdayNum {
n: None,
weekday: Weekday::We,
},
WeekdayNum {
n: None,
weekday: Weekday::Th,
},
WeekdayNum {
n: None,
weekday: Weekday::Fr,
},
]);
return Ok(());
}
}
if obj.freq == Frequency::Weekly {
let toks: Vec<&str> = s.split_whitespace().collect();
let has_weekday = toks.iter().any(|t| *t == "weekday" || *t == "weekdays");
let has_weekend = toks.iter().any(|t| *t == "weekend" || *t == "weekends");
if has_weekday {
obj.byday = Some(vec![
WeekdayNum {
n: None,
weekday: Weekday::Mo,
},
WeekdayNum {
n: None,
weekday: Weekday::Tu,
},
WeekdayNum {
n: None,
weekday: Weekday::We,
},
WeekdayNum {
n: None,
weekday: Weekday::Th,
},
WeekdayNum {
n: None,
weekday: Weekday::Fr,
},
]);
return Ok(());
}
if has_weekend {
obj.byday = Some(vec![
WeekdayNum {
n: None,
weekday: Weekday::Sa,
},
WeekdayNum {
n: None,
weekday: Weekday::Su,
},
]);
return Ok(());
}
if let Some(days) = capture_weekday_list(s) {
obj.byday = Some(
days.into_iter()
.map(|w| WeekdayNum {
n: None,
weekday: w,
})
.collect(),
);
return Ok(());
}
if let Some(day) = capture_single_weekday_after_every(s) {
obj.byday = Some(vec![WeekdayNum {
n: None,
weekday: day,
}]);
return Ok(());
}
}
if let Some(days) = capture_weekday_list(s) {
obj.byday = Some(
days.into_iter()
.map(|w| WeekdayNum {
n: None,
weekday: w,
})
.collect(),
);
return Ok(());
}
Ok(())
}
fn build_rrule(obj: &RecurrenceObject) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(format!("FREQ={}", freq_to_str(&obj.freq)));
if let Some(interval) = obj.interval {
if interval != 1 {
parts.push(format!("INTERVAL={}", interval));
}
}
if let Some(byday) = &obj.byday {
let s = byday
.iter()
.map(|wdn| {
if let Some(n) = wdn.n {
format!("{}{}", n, wdn.weekday.as_str())
} else {
wdn.weekday.as_str().to_string()
}
})
.collect::<Vec<_>>()
.join(",");
parts.push(format!("BYDAY={}", s));
}
if let Some(bymonth) = &obj.bymonth {
parts.push(format!(
"BYMONTH={}",
bymonth
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join(",")
));
}
if let Some(bymonthday) = &obj.bymonthday {
parts.push(format!(
"BYMONTHDAY={}",
bymonthday
.iter()
.map(|d| d.to_string())
.collect::<Vec<_>>()
.join(",")
));
}
if let Some(byhour) = &obj.byhour {
parts.push(format!(
"BYHOUR={}",
byhour
.iter()
.map(|h| h.to_string())
.collect::<Vec<_>>()
.join(",")
));
}
if let Some(byminute) = &obj.byminute {
parts.push(format!(
"BYMINUTE={}",
byminute
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join(",")
));
}
if let Some(count) = obj.count {
parts.push(format!("COUNT={}", count));
}
if let Some(until) = obj.until.as_deref() {
let until_rrule = if until.contains('T') {
let has_z = until.ends_with('z') || until.ends_with('Z');
let base = until.trim_end_matches('z').trim_end_matches('Z');
let cleaned = base.replace('-', "").replace(':', "");
if has_z {
format!("{}Z", cleaned)
} else {
cleaned
}
} else {
until.replace('-', "")
};
parts.push(format!("UNTIL={}", until_rrule));
}
format!("RRULE:{}", parts.join(";"))
}
fn freq_to_str(f: &Frequency) -> &'static str {
match f {
Frequency::Yearly => "YEARLY",
Frequency::Monthly => "MONTHLY",
Frequency::Weekly => "WEEKLY",
Frequency::Daily => "DAILY",
Frequency::Hourly => "HOURLY",
Frequency::Minutely => "MINUTELY",
Frequency::Secondly => "SECONDLY",
}
}
fn unit_to_freq(unit: &str) -> Result<Frequency, String> {
match unit {
"day" | "days" => Ok(Frequency::Daily),
"week" | "weeks" => Ok(Frequency::Weekly),
"month" | "months" => Ok(Frequency::Monthly),
"year" | "years" => Ok(Frequency::Yearly),
"hour" | "hours" => Ok(Frequency::Hourly),
"minute" | "minutes" => Ok(Frequency::Minutely),
"second" | "seconds" => Ok(Frequency::Secondly),
_ => Err(format!("Unsupported interval unit: {}", unit)),
}
}
fn capture_every_n_unit(s: &str) -> Option<(u32, String)> {
let tokens: Vec<&str> = s.split_whitespace().collect();
for i in 0..tokens.len().saturating_sub(2) {
if (tokens[i] == "every" || tokens[i] == "each")
&& tokens[i + 1].chars().all(|c| c.is_ascii_digit())
{
let n: u32 = tokens[i + 1].parse().ok()?;
let unit = tokens[i + 2].to_string();
return Some((n, unit));
}
}
None
}
fn strip_every_n_unit(s: &str, n: u32, unit: &str) -> String {
let needle1 = format!("every {} {}", n, unit);
let needle2 = format!("each {} {}", n, unit);
strip_phrases(s, &[needle1.as_str(), needle2.as_str()])
}
fn capture_every_unit(s: &str) -> Option<String> {
let toks: Vec<&str> = s.split_whitespace().collect();
for i in 0..toks.len().saturating_sub(1) {
if toks[i] == "every" || toks[i] == "each" {
let unit = toks[i + 1];
match unit {
"day" | "days" | "week" | "weeks" | "month" | "months" | "year" | "years"
| "hour" | "hours" | "minute" | "minutes" | "second" | "seconds" => {
return Some(unit.to_string());
}
_ => {}
}
}
}
None
}
fn strip_unit_only(s: &str, unit: &str) -> String {
let toks: Vec<&str> = s.split_whitespace().collect();
let mut out: Vec<&str> = Vec::new();
let mut i = 0;
while i < toks.len() {
if (toks[i] == "every" || toks[i] == "each") && i + 1 < toks.len() && toks[i + 1] == unit {
i += 2; continue;
}
out.push(toks[i]);
i += 1;
}
out.join(" ")
}
fn strip_phrases(s: &str, needles: &[&str]) -> String {
let mut out = s.to_string();
for n in needles {
out = out.replace(n, "").trim().to_string();
}
out
}
fn captures_unit_after_phrase(s: &str, phrase: &str) -> Option<String> {
let idx = s.find(phrase)?;
let after = &s[idx + phrase.len()..];
let unit = after.trim().split_whitespace().next()?;
Some(unit.to_string())
}
fn extract_count(s: &str) -> Option<u32> {
let tokens: Vec<&str> = s.split_whitespace().collect();
for i in 0..tokens.len().saturating_sub(2) {
if tokens[i] == "for" && tokens[i + 1].chars().all(|c| c.is_ascii_digit()) {
let n: u32 = tokens[i + 1].parse().ok()?;
if tokens[i + 2].starts_with("time") || tokens[i + 2].starts_with("occurrence") {
return Some(n);
}
}
}
None
}
fn extract_until(s: &str) -> Result<Option<String>, String> {
if !s.contains(" until ") && !s.starts_with("until ") {
return Ok(None);
}
let idx = s.find("until").unwrap();
let after = s[idx + "until".len()..].trim();
if after.is_empty() {
return Ok(None);
}
if let Some(date) = capture_iso_date(after) {
return Ok(Some(date));
}
if let Some(date) = capture_month_name_date(after) {
return Ok(Some(date));
}
Ok(None)
}
fn capture_iso_date(s: &str) -> Option<String> {
let first = s.split_whitespace().next()?;
if first.len() == 10
&& first.chars().nth(4) == Some('-')
&& first.chars().nth(7) == Some('-')
&& first[0..4].chars().all(|c| c.is_ascii_digit())
&& first[5..7].chars().all(|c| c.is_ascii_digit())
&& first[8..10].chars().all(|c| c.is_ascii_digit())
{
return Some(first.to_string());
}
None
}
fn capture_month_name_date(s: &str) -> Option<String> {
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() < 3 {
return None;
}
let month = parse_month_name(parts[0])?;
let day = parse_ordinal_or_int(parts[1])?;
let year: i32 = parts[2].parse().ok()?;
Some(format!("{:04}-{:02}-{:02}", year, month, day))
}
fn extract_time(s: &str) -> Result<(Option<u32>, Option<u32>), String> {
let tokens: Vec<&str> = s.split_whitespace().collect();
for i in 0..tokens.len().saturating_sub(1) {
if tokens[i] == "at" {
let t = tokens[i + 1];
if let Some((h, m)) = parse_hhmm(t) {
return Ok((Some(h), Some(m)));
}
return Err("Could not parse time after 'at'".to_string());
}
}
Ok((None, None))
}
fn parse_hhmm(s: &str) -> Option<(u32, u32)> {
let mut it = s.split(':');
let h = it.next()?.parse::<u32>().ok()?;
let m = it.next()?.parse::<u32>().ok()?;
if h <= 23 && m <= 59 {
Some((h, m))
} else {
None
}
}
fn capture_single_weekday_after_every(s: &str) -> Option<Weekday> {
let tokens: Vec<&str> = s.split_whitespace().collect();
for i in 0..tokens.len().saturating_sub(1) {
if tokens[i] == "every" || tokens[i] == "each" {
if let Some(w) = parse_weekday_name(tokens[i + 1]) {
return Some(w);
}
}
}
None
}
fn capture_weekday_list(s: &str) -> Option<Vec<Weekday>> {
let mut days: Vec<Weekday> = Vec::new();
let segment = if let Some(idx) = s.find(" on ") {
&s[idx + 4..]
} else if let Some(idx) = s.find("every ") {
&s[idx + 6..]
} else {
s
};
for token in segment.split_whitespace() {
if token == "and" {
continue;
}
if let Some(w) = parse_weekday_name(token) {
if !days.contains(&w) {
days.push(w);
}
}
}
if days.is_empty() { None } else { Some(days) }
}
fn capture_nth_weekday(s: &str) -> Result<Option<WeekdayNum>, String> {
let tokens: Vec<&str> = s.split_whitespace().collect();
for i in 0..tokens.len().saturating_sub(2) {
if tokens[i] == "every" || tokens[i] == "each" {
if let Some(n) = tokens.get(i + 1).and_then(|t| parse_nth_token(t)) {
if let Some(w) = tokens.get(i + 2).and_then(|t| parse_weekday_name(t)) {
return Ok(Some(WeekdayNum {
n: Some(n),
weekday: w,
}));
}
}
if let Some(wd) = tokens.get(i + 1).and_then(|t| parse_weekday_name(t)) {
let wd = wd;
if tokens.get(i + 2) == Some(&"the") && tokens.get(i + 3) == Some(&"last") {
return Ok(Some(WeekdayNum {
n: Some(-1),
weekday: wd,
}));
}
if tokens.get(i + 2) == Some(&"last") {
return Ok(Some(WeekdayNum {
n: Some(-1),
weekday: wd,
}));
}
}
}
}
let mut i = 0;
while i < tokens.len() {
if tokens[i] == "on" {
let mut j = i + 1;
if j < tokens.len() && tokens[j] == "the" {
j += 1;
}
if j >= tokens.len() {
break;
}
if tokens[j].contains("to-last")
|| tokens[j].contains("to") && tokens.get(j + 2) == Some(&"last")
{
}
if let Some(n) = parse_nth_token(tokens[j]) {
if let Some(w) = tokens.get(j + 1).and_then(|t| parse_weekday_name(t)) {
return Ok(Some(WeekdayNum {
n: Some(n),
weekday: w,
}));
}
}
if let Some(n) = parse_ordinal_word(tokens[j]) {
if tokens.get(j + 1) == Some(&"last") {
if let Some(w) = tokens.get(j + 2).and_then(|t| parse_weekday_name(t)) {
return Ok(Some(WeekdayNum {
n: Some(-(n as i32)),
weekday: w,
}));
}
}
}
if let Some(n) = parse_to_last_compound(tokens[j]) {
if let Some(w) = tokens.get(j + 1).and_then(|t| parse_weekday_name(t)) {
return Ok(Some(WeekdayNum {
n: Some(n),
weekday: w,
}));
}
}
if let Some(n_pos) = parse_ordinal_word(tokens[j]) {
if tokens.get(j + 1) == Some(&"to") && tokens.get(j + 2) == Some(&"last") {
if let Some(w) = tokens.get(j + 3).and_then(|t| parse_weekday_name(t)) {
return Ok(Some(WeekdayNum {
n: Some(-(n_pos as i32)),
weekday: w,
}));
}
}
}
if let Some(n_pos) = parse_nth_token(tokens[j]) {
if tokens.get(j + 1) == Some(&"last") {
if let Some(w) = tokens.get(j + 2).and_then(|t| parse_weekday_name(t)) {
return Ok(Some(WeekdayNum {
n: Some(-n_pos.abs()),
weekday: w,
}));
}
}
}
}
i += 1;
}
Ok(None)
}
fn capture_monthdays_list(s: &str) -> Option<Vec<i32>> {
let mut segment = if let Some(idx) = s.find(" on ") {
&s[idx + 4..]
} else {
s
};
if let Some(i) = segment.find(" for ") {
segment = &segment[..i];
}
if let Some(i) = segment.find(" until ") {
segment = &segment[..i];
}
let mut days: Vec<i32> = Vec::new();
for tok in segment.split_whitespace() {
if tok == "for" || tok == "until" {
break;
}
if tok == "and" || tok == "the" {
continue;
}
if parse_weekday_name(tok).is_some() {
continue;
}
if let Some(d) = parse_ordinal_or_int(tok) {
if (1..=31).contains(&d) {
days.push(d as i32);
}
}
}
if days.is_empty() { None } else { Some(days) }
}
fn capture_month_and_monthday(s: &str) -> Option<(u32, i32)> {
let tokens: Vec<&str> = s.split_whitespace().collect();
for i in 0..tokens.len() {
if tokens[i] == "on" {
let mut j = i + 1;
if j < tokens.len() && tokens[j] == "the" {
j += 1;
}
if let Some(month) = tokens.get(j).and_then(|t| parse_month_name(t)) {
if let Some(day_tok) = tokens.get(j + 1) {
if let Some(day) = parse_ordinal_or_int(day_tok) {
return Some((month, day as i32));
}
}
}
if let Some(day) = tokens.get(j).and_then(|t| parse_ordinal_or_int(t)) {
if let Some(next) = tokens.get(j + 1) {
if *next == "of" {
if let Some(month) = tokens.get(j + 2).and_then(|t| parse_month_name(t)) {
return Some((month, day as i32));
}
} else if let Some(month) = tokens.get(j + 1).and_then(|t| parse_month_name(t))
{
return Some((month, day as i32));
}
}
}
}
if let Some(month) = tokens.get(i).and_then(|t| parse_month_name(t)) {
if let Some(next) = tokens.get(i + 1) {
if *next == "on" {
let mut j = i + 2;
if j < tokens.len() && tokens[j] == "the" {
j += 1;
}
if let Some(day_tok) = tokens.get(j) {
if let Some(day) = parse_ordinal_or_int(day_tok) {
return Some((month, day as i32));
}
}
} else if *next == "the" {
if let Some(day_tok) = tokens.get(i + 2) {
if let Some(day) = parse_ordinal_or_int(day_tok) {
return Some((month, day as i32));
}
}
} else if let Some(day) = parse_ordinal_or_int(next) {
return Some((month, day as i32));
}
}
}
}
None
}
fn parse_weekday_name(s: &str) -> Option<Weekday> {
match s.trim_end_matches('s') {
"monday" | "mon" => Some(Weekday::Mo),
"tuesday" | "tue" | "tues" => Some(Weekday::Tu),
"wednesday" | "wed" => Some(Weekday::We),
"thursday" | "thu" | "thur" | "thurs" => Some(Weekday::Th),
"friday" | "fri" => Some(Weekday::Fr),
"saturday" | "sat" => Some(Weekday::Sa),
"sunday" | "sun" => Some(Weekday::Su),
_ => None,
}
}
fn parse_month_name(s: &str) -> Option<u32> {
match s {
"jan" | "january" => Some(1),
"feb" | "february" => Some(2),
"mar" | "march" => Some(3),
"apr" | "april" => Some(4),
"may" => Some(5),
"jun" | "june" => Some(6),
"jul" | "july" => Some(7),
"aug" | "august" => Some(8),
"sep" | "sept" | "september" => Some(9),
"oct" | "october" => Some(10),
"nov" | "november" => Some(11),
"dec" | "december" => Some(12),
_ => None,
}
}
fn parse_nth_token(tok: &str) -> Option<i32> {
if tok == "last" {
return Some(-1);
}
if let Some(n) = parse_to_last_compound(tok) {
return Some(n);
}
if tok.ends_with("st") || tok.ends_with("nd") || tok.ends_with("rd") || tok.ends_with("th") {
let num = tok
.trim_end_matches("st")
.trim_end_matches("nd")
.trim_end_matches("rd")
.trim_end_matches("th");
return num.parse::<i32>().ok().filter(|n| (1..=5).contains(n));
}
None
}
fn parse_to_last_compound(tok: &str) -> Option<i32> {
if !tok.contains("to-last") {
return None;
}
let cleaned = tok.replace("-to-last", "").replace("to-last", "");
match cleaned.as_str() {
"second" => Some(-2),
"third" => Some(-3),
"fourth" => Some(-4),
"fifth" => Some(-5),
_ => {
let n = cleaned
.trim_end_matches("st")
.trim_end_matches("nd")
.trim_end_matches("rd")
.trim_end_matches("th")
.parse::<i32>()
.ok()?;
(1..=5).contains(&n).then_some(-n)
}
}
}
fn parse_ordinal_word(tok: &str) -> Option<u32> {
match tok {
"first" => Some(1),
"second" => Some(2),
"third" => Some(3),
"fourth" => Some(4),
"fifth" => Some(5),
_ => None,
}
}
fn parse_ordinal_or_int(tok: &str) -> Option<u32> {
if let Some(n) = parse_ordinal_word(tok) {
return Some(n);
}
let cleaned = tok
.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-')
.trim_end_matches(|c: char| !c.is_ascii_alphanumeric())
.to_string();
let stripped = cleaned
.trim_end_matches("st")
.trim_end_matches("nd")
.trim_end_matches("rd")
.trim_end_matches("th");
stripped.parse::<u32>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_rec(input: &str, expected_rrule: Option<&str>) {
let res = human_text_to_recurrence(input);
let expected = expected_rrule.map(|s| s.to_string());
assert_eq!(res.recurrence, expected);
}
fn assert_rec_and_text(input: &str, expected_rrule: Option<&str>, expected_text: Option<&str>) {
let res = human_text_to_recurrence(input);
let expected_rrule_str = expected_rrule.map(|s| s.to_string());
let expected_text_str = expected_text.map(|s| s.to_string());
assert_eq!(res.recurrence, expected_rrule_str);
assert_eq!(res.parsed_text, expected_text_str);
}
#[test]
fn daily_variants() {
assert_rec("every day", Some("RRULE:FREQ=DAILY"));
assert_rec("daily", Some("RRULE:FREQ=DAILY"));
assert_rec("every 2 days", Some("RRULE:FREQ=DAILY;INTERVAL=2"));
}
#[test]
fn weekly_variants() {
assert_rec("weekly", Some("RRULE:FREQ=WEEKLY"));
assert_rec("every week", Some("RRULE:FREQ=WEEKLY"));
assert_rec("every other week", Some("RRULE:FREQ=WEEKLY;INTERVAL=2"));
assert_rec("biweekly", Some("RRULE:FREQ=WEEKLY;INTERVAL=2"));
assert_rec("every Monday", Some("RRULE:FREQ=WEEKLY;BYDAY=MO"));
assert_rec(
"every 2 weeks on monday and wednesday",
Some("RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE"),
);
assert_rec(
"every weekday",
Some("RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"),
);
assert_rec("every weekend", Some("RRULE:FREQ=WEEKLY;BYDAY=SA,SU"));
}
#[test]
fn monthly_bymonthday() {
assert_rec("every month", Some("RRULE:FREQ=MONTHLY"));
assert_rec(
"every month on the 1st and 15th",
Some("RRULE:FREQ=MONTHLY;BYMONTHDAY=1,15"),
);
assert_rec(
"every 3 months on 10th 20th",
Some("RRULE:FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10,20"),
);
assert_rec(
"every last day of the month",
Some("RRULE:FREQ=MONTHLY;BYMONTHDAY=-1"),
);
assert_rec(
"every 2 months on the last day",
Some("RRULE:FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=-1"),
);
}
#[test]
fn monthly_nth_weekday_edge_cases() {
assert_rec(
"every month on the last friday",
Some("RRULE:FREQ=MONTHLY;BYDAY=-1FR"),
);
assert_rec(
"every month on the 2nd friday",
Some("RRULE:FREQ=MONTHLY;BYDAY=2FR"),
);
assert_rec(
"every month on the 2nd-to-last friday",
Some("RRULE:FREQ=MONTHLY;BYDAY=-2FR"),
);
assert_rec(
"every month on the second last friday",
Some("RRULE:FREQ=MONTHLY;BYDAY=-2FR"),
);
assert_rec(
"every month on the second to last friday",
Some("RRULE:FREQ=MONTHLY;BYDAY=-2FR"),
);
assert_rec(
"every month on the 2nd last friday for 7 times",
Some("RRULE:FREQ=MONTHLY;BYDAY=-2FR;COUNT=7"),
);
let r = human_text_to_recurrence("Every Tuesday the last");
assert_eq!(
r.recurrence,
Some("RRULE:FREQ=MONTHLY;BYDAY=-1TU".to_string())
);
assert_eq!(r.parsed_text, Some(normalize("Every Tuesday the last")));
let r = human_text_to_recurrence("every tuesday last");
assert_eq!(
r.recurrence,
Some("RRULE:FREQ=MONTHLY;BYDAY=-1TU".to_string())
);
assert_eq!(r.parsed_text, Some(normalize("every tuesday last")));
let r = human_text_to_recurrence("each tue the last");
assert_eq!(
r.recurrence,
Some("RRULE:FREQ=MONTHLY;BYDAY=-1TU".to_string())
);
assert_eq!(r.parsed_text, Some(normalize("each tue the last")));
let r = human_text_to_recurrence("every month on the 10th and 15th for 20 times");
assert_eq!(
r.recurrence,
Some("RRULE:FREQ=MONTHLY;BYMONTHDAY=10,15;COUNT=20".to_string())
);
assert_eq!(
r.parsed_text,
Some(normalize("every month on the 10th and 15th for 20 times"))
);
}
#[test]
fn yearly_month_and_day() {
assert_rec("every year", Some("RRULE:FREQ=YEARLY"));
assert_rec("annually", Some("RRULE:FREQ=YEARLY"));
assert_rec(
"every year on March 5th",
Some("RRULE:FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=5"),
);
assert_rec(
"every 2 years on 5th march",
Some("RRULE:FREQ=YEARLY;INTERVAL=2;BYMONTH=3;BYMONTHDAY=5"),
);
}
#[test]
fn count_until_time_suffixes() {
assert_rec(
"every friday for 3 times",
Some("RRULE:FREQ=WEEKLY;BYDAY=FR;COUNT=3"),
);
assert_rec(
"every friday until 2026-12-31",
Some("RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20261231"),
);
assert_rec(
"every friday at 08:30",
Some("RRULE:FREQ=WEEKLY;BYDAY=FR;BYHOUR=8;BYMINUTE=30"),
);
assert_rec(
"every 2 weeks on monday at 9:05 for 10 times until dec 31 2026",
Some(
"RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;BYHOUR=9;BYMINUTE=5;COUNT=10;UNTIL=20261231",
),
);
}
#[test]
fn roundtrip_with_human_text_outputs() {
let cases = [
("RRULE:FREQ=DAILY", "every day"),
("RRULE:FREQ=WEEKLY", "every week"),
(
"RRULE:FREQ=WEEKLY;BYDAY=FR;COUNT=3",
"every Friday for 3 times",
),
(
"RRULE:FREQ=MONTHLY;BYMONTHDAY=-1",
"every last day of the month",
),
(
"RRULE:FREQ=MONTHLY;BYMONTHDAY=1,15",
"every month on the 1st and 15th",
),
(
"RRULE:FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=5",
"every year on March 5th",
),
];
for (rrule, human) in cases {
let back = human_text_to_recurrence(human);
assert_eq!(back.recurrence, Some(rrule.to_string()));
assert_eq!(back.parsed_text, Some(normalize(human)));
}
}
#[test]
fn returns_none_for_invalid() {
let r = human_text_to_recurrence("nonsense input");
assert_eq!(r.recurrence, None);
assert_eq!(r.parsed_text, None);
let r = human_text_to_recurrence("");
assert_eq!(r.recurrence, None);
assert_eq!(r.parsed_text, None);
let r = human_text_to_recurrence("repeat forever");
assert_eq!(r.recurrence, None);
assert_eq!(r.parsed_text, None);
}
#[test]
fn ignores_unrelated_text() {
assert_rec("buy milk every friday", Some("RRULE:FREQ=WEEKLY;BYDAY=FR"));
assert_rec(
"call mom every monday and wednesday",
Some("RRULE:FREQ=WEEKLY;BYDAY=MO,WE"),
);
assert_rec(
"meeting every 2 weeks on tuesday",
Some("RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU"),
);
assert_rec(
"reminder every last day of the month",
Some("RRULE:FREQ=MONTHLY;BYMONTHDAY=-1"),
);
assert_rec_and_text(
"test every day abc123",
Some("RRULE:FREQ=DAILY"),
Some("every day"),
);
assert_rec_and_text(
"test every day for 3 times up to 5 times abc123",
Some("RRULE:FREQ=DAILY;COUNT=3"),
Some("every day for 3 times"),
);
assert_rec_and_text(
"every february on the 29th",
Some("RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29"),
Some("every february on the 29th"),
);
assert_rec_and_text(
"every february the 29th",
Some("RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29"),
Some("every february the 29th"),
);
assert_rec_and_text(
"every second-to-last friday of the month for 6 times",
Some("RRULE:FREQ=MONTHLY;BYDAY=-2FR;COUNT=6"),
Some("every second-to-last friday of the month for 6 times"),
);
assert_rec_and_text(
"every last day of the month except february",
Some("RRULE:FREQ=MONTHLY;BYMONTHDAY=-1"),
Some("every last day of the month"),
);
assert_rec_and_text(
"every day until abc123",
Some("RRULE:FREQ=DAILY"),
Some("every day"),
);
assert_rec_and_text("every day for", Some("RRULE:FREQ=DAILY"), Some("every day"));
assert_rec_and_text(
"every day for abc123",
Some("RRULE:FREQ=DAILY"),
Some("every day"),
);
}
}