use std::path::{Path, PathBuf};
use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, TimeZone, Utc, Weekday};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use uuid::Uuid;
use crate::error::{Error, Result};
use crate::model::{Priority, Task};
use crate::storage;
pub const DIR: &str = ".recurring";
pub const SYNTAX: &str = "daily | weekly:mon,thu | monthly:1,15 | every:3d";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Rule {
Daily,
Weekly(Vec<Weekday>),
Monthly(Vec<u32>),
EveryDays(u32),
}
fn parse_weekday(s: &str) -> std::result::Result<Weekday, String> {
match s {
"mon" => Ok(Weekday::Mon),
"tue" => Ok(Weekday::Tue),
"wed" => Ok(Weekday::Wed),
"thu" => Ok(Weekday::Thu),
"fri" => Ok(Weekday::Fri),
"sat" => Ok(Weekday::Sat),
"sun" => Ok(Weekday::Sun),
other => Err(format!("unknown weekday '{other}', expected mon..sun")),
}
}
fn weekday_name(day: Weekday) -> &'static str {
match day {
Weekday::Mon => "mon",
Weekday::Tue => "tue",
Weekday::Wed => "wed",
Weekday::Thu => "thu",
Weekday::Fri => "fri",
Weekday::Sat => "sat",
Weekday::Sun => "sun",
}
}
impl std::str::FromStr for Rule {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let s = s.trim().to_lowercase();
let (head, arg) = match s.split_once(':') {
Some((h, a)) => (h, a.trim()),
None => (s.as_str(), ""),
};
match (head, arg) {
("daily", "") => Ok(Rule::Daily),
("weekly", days) if !days.is_empty() => {
let mut parsed = days
.split(',')
.map(|d| parse_weekday(d.trim()))
.collect::<std::result::Result<Vec<_>, _>>()?;
parsed.sort_by_key(|d| d.num_days_from_monday());
parsed.dedup();
Ok(Rule::Weekly(parsed))
}
("monthly", days) if !days.is_empty() => {
let mut parsed = Vec::new();
for day in days.split(',') {
let n: u32 = day
.trim()
.parse()
.map_err(|_| format!("invalid day of month '{}'", day.trim()))?;
if !(1..=31).contains(&n) {
return Err(format!("day of month must be 1-31, got {n}"));
}
parsed.push(n);
}
parsed.sort_unstable();
parsed.dedup();
Ok(Rule::Monthly(parsed))
}
("every", spec) if !spec.is_empty() => {
let n: u32 = spec
.strip_suffix('d')
.ok_or_else(|| format!("interval must end with 'd', got '{spec}'"))?
.parse()
.map_err(|_| format!("invalid interval '{spec}'"))?;
if n == 0 {
return Err("interval must be at least 1 day".into());
}
Ok(Rule::EveryDays(n))
}
_ => Err(format!("expected one of: {SYNTAX}")),
}
}
}
impl std::fmt::Display for Rule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Rule::Daily => f.write_str("daily"),
Rule::Weekly(days) => {
let names: Vec<&str> = days.iter().map(|d| weekday_name(*d)).collect();
write!(f, "weekly:{}", names.join(","))
}
Rule::Monthly(days) => {
let names: Vec<String> = days.iter().map(|d| d.to_string()).collect();
write!(f, "monthly:{}", names.join(","))
}
Rule::EveryDays(n) => write!(f, "every:{n}d"),
}
}
}
impl Serialize for Rule {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Rule {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let raw = String::deserialize(d)?;
raw.parse().map_err(serde::de::Error::custom)
}
}
fn last_day_of_month(year: i32, month: u32) -> u32 {
let (next_year, next_month) = if month == 12 { (year + 1, 1) } else { (year, month + 1) };
NaiveDate::from_ymd_opt(next_year, next_month, 1)
.and_then(|d| d.pred_opt())
.map(|d| d.day())
.unwrap_or(28)
}
impl Rule {
pub fn first_on_or_after(&self, from: NaiveDate, anchor: NaiveDate) -> NaiveDate {
match self {
Rule::Daily => from,
Rule::Weekly(days) => (0..7)
.map(|i| from + Duration::days(i))
.find(|d| days.contains(&d.weekday()))
.unwrap_or(from),
Rule::Monthly(days) => {
let mut year = from.year();
let mut month = from.month();
for _ in 0..13 {
let last = last_day_of_month(year, month);
for day in days {
let candidate = NaiveDate::from_ymd_opt(year, month, (*day).min(last));
if let Some(c) = candidate
&& c >= from
{
return c;
}
}
if month == 12 {
year += 1;
month = 1;
} else {
month += 1;
}
}
from
}
Rule::EveryDays(n) => {
if from <= anchor {
return anchor;
}
let step = *n as i64;
let gap = (from - anchor).num_days();
let cycles = (gap + step - 1) / step;
anchor + Duration::days(cycles * step)
}
}
}
pub fn next_after(&self, after: NaiveDate, anchor: NaiveDate) -> NaiveDate {
self.first_on_or_after(after + Duration::days(1), anchor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleMeta {
pub id: Uuid,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub priority: Priority,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
pub rule: Rule,
#[serde(default = "enabled_default")]
pub enabled: bool,
pub start_date: NaiveDate,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<NaiveDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_count: Option<u64>,
pub next_run: NaiveDate,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_run: Option<NaiveDate>,
#[serde(default)]
pub created_count: u64,
pub created_at: DateTime<Utc>,
}
fn enabled_default() -> bool {
true
}
impl RuleMeta {
pub fn new(title: String, rule: Rule, start_date: NaiveDate) -> Self {
RuleMeta {
id: Uuid::new_v4(),
title,
description: None,
priority: Priority::default(),
tags: Vec::new(),
next_run: rule.first_on_or_after(start_date, start_date),
rule,
enabled: true,
start_date,
until: None,
max_count: None,
last_run: None,
created_count: 0,
created_at: Utc::now(),
}
}
pub fn short_id(&self) -> String {
self.id.simple().to_string()[..8].to_string()
}
pub fn exhausted(&self) -> bool {
let past_end = self.until.is_some_and(|until| self.next_run > until);
let hit_cap = self.max_count.is_some_and(|max| self.created_count >= max);
past_end || hit_cap
}
fn validate(&self) -> std::result::Result<(), String> {
if self.title.trim().is_empty() {
return Err("field 'title' must not be empty".into());
}
if let Some(until) = self.until
&& until < self.start_date
{
return Err("field 'until' is before 'start_date'".into());
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct StoredRule {
pub path: PathBuf,
pub meta: RuleMeta,
pub body: String,
}
const DELIM: &str = "---";
pub fn parse_rule(content: &str) -> Result<(RuleMeta, String)> {
let rest = content
.strip_prefix(DELIM)
.and_then(|r| r.strip_prefix('\n'))
.ok_or_else(|| Error::InvalidTaskFile("missing front matter".into()))?;
let end = rest
.find("\n---")
.ok_or_else(|| Error::InvalidTaskFile("unterminated front matter".into()))?;
let body_start = rest[end + 1..]
.find('\n')
.map(|i| end + 1 + i + 1)
.unwrap_or(rest.len());
let meta: RuleMeta = serde_yaml::from_str(&rest[..end])?;
meta.validate().map_err(Error::InvalidTaskFile)?;
Ok((meta, rest[body_start..].trim_start_matches('\n').to_string()))
}
pub fn render_rule(meta: &RuleMeta, body: &str) -> Result<String> {
let yaml = serde_yaml::to_string(meta)?;
let mut out = format!("{DELIM}\n{yaml}{DELIM}\n");
if !body.is_empty() {
out.push('\n');
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
}
Ok(out)
}
pub fn dir(root: &Path) -> PathBuf {
root.join(DIR)
}
pub fn save(stored: &StoredRule) -> Result<()> {
if let Some(parent) = stored.path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&stored.path, render_rule(&stored.meta, &stored.body)?)?;
Ok(())
}
pub fn load_all(root: &Path) -> Result<Vec<StoredRule>> {
let dir = dir(root);
if !dir.exists() {
return Ok(Vec::new());
}
let mut rules = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let path = entry?.path();
if !path.extension().is_some_and(|e| e == "md") {
continue;
}
let content = std::fs::read_to_string(&path)?;
let (meta, body) = parse_rule(&content).map_err(|e| {
Error::InvalidTaskFile(format!("{}: {}", path.display(), strip_prefix(&e)))
})?;
rules.push(StoredRule { path, meta, body });
}
rules.sort_by(|a, b| {
a.meta
.next_run
.cmp(&b.meta.next_run)
.then_with(|| a.meta.title.cmp(&b.meta.title))
});
Ok(rules)
}
fn strip_prefix(err: &Error) -> String {
match err {
Error::InvalidTaskFile(msg) => msg.clone(),
other => other.to_string(),
}
}
pub fn resolve(root: &Path, query: &str) -> Result<StoredRule> {
let query = query.trim();
let hex = query.replace('-', "").to_lowercase();
let by_id = hex.len() >= 4 && hex.chars().all(|c| c.is_ascii_hexdigit());
let needle = query.to_lowercase();
let mut matches: Vec<StoredRule> = load_all(root)?
.into_iter()
.filter(|r| {
(by_id && r.meta.id.simple().to_string().starts_with(&hex))
|| r.meta.title.to_lowercase().contains(&needle)
})
.collect();
match matches.len() {
0 => Err(Error::TaskNotFound(format!("rule '{query}'"))),
1 => Ok(matches.remove(0)),
_ => Err(Error::AmbiguousId(query.to_string())),
}
}
pub fn rule_path(root: &Path, meta: &RuleMeta) -> PathBuf {
let slug = storage::slugify(&meta.title);
let stem = if slug.is_empty() {
meta.short_id()
} else {
format!("{slug}-{}", meta.short_id())
};
dir(root).join(format!("{stem}.md"))
}
#[derive(Debug)]
pub struct Generated {
pub seq: u64,
pub title: String,
pub occurrence: NaiveDate,
pub path: PathBuf,
}
fn due_at(date: NaiveDate) -> DateTime<Utc> {
let naive = date.and_hms_opt(23, 59, 59).expect("23:59:59 is a valid time");
Local
.from_local_datetime(&naive)
.earliest()
.map(|dt| dt.to_utc())
.unwrap_or_else(|| naive.and_utc())
}
fn materialise(root: &Path, stored: &StoredRule, occurrence: NaiveDate) -> Result<Generated> {
let seq = storage::LibraryMeta::allocate_seq(root)?;
let mut task = Task::new(seq, stored.meta.title.clone());
task.meta.description = stored.meta.description.clone();
let template = crate::commands::template::Template {
meta: crate::commands::template::TemplateMeta {
status: None,
priority: Some(stored.meta.priority),
tags: Some(stored.meta.tags.clone()),
},
body: stored.body.clone(),
};
crate::commands::template::apply(&template, &mut task, false, false);
task.meta.due_date = Some(due_at(occurrence));
task.meta.recur_id = Some(stored.meta.id);
task.meta.occurrence = Some(occurrence);
let name = storage::render_filename(&crate::commands::task::filename_template(root)?, &task)?;
let path = root.join(name);
storage::write_task(&path, &task)?;
Ok(Generated { seq, title: task.meta.title, occurrence, path })
}
pub fn run_due(root: &Path, today: NaiveDate) -> Result<Vec<Generated>> {
let mut generated = Vec::new();
for mut stored in load_all(root)? {
if !stored.meta.enabled || stored.meta.exhausted() {
continue;
}
let horizon = stored.meta.until.map_or(today, |until| until.min(today));
if stored.meta.next_run > horizon {
continue;
}
let mut occurrence = stored.meta.next_run;
loop {
let next = stored.meta.rule.next_after(occurrence, stored.meta.start_date);
if next > horizon {
break;
}
occurrence = next;
}
generated.push(materialise(root, &stored, occurrence)?);
stored.meta.created_count += 1;
stored.meta.last_run = Some(occurrence);
stored.meta.next_run = stored.meta.rule.next_after(today, stored.meta.start_date);
save(&stored)?;
}
Ok(generated)
}
#[cfg(test)]
mod tests {
use super::*;
fn date(s: &str) -> NaiveDate {
s.parse().unwrap()
}
#[test]
fn rule_syntax_round_trip() {
for s in ["daily", "weekly:mon,thu", "monthly:1,15", "every:3d"] {
let rule: Rule = s.parse().unwrap();
assert_eq!(rule.to_string(), s);
}
}
#[test]
fn rule_parsing_normalises_input() {
assert_eq!("WEEKLY:THU,MON,mon".parse::<Rule>().unwrap().to_string(), "weekly:mon,thu");
assert_eq!("monthly: 15 , 1".parse::<Rule>().unwrap().to_string(), "monthly:1,15");
}
#[test]
fn rule_parsing_rejects_nonsense() {
for s in ["", "daily:1", "weekly", "weekly:funday", "monthly:0", "monthly:32", "every:3", "every:0d", "cron"] {
assert!(s.parse::<Rule>().is_err(), "expected error for '{s}'");
}
}
#[test]
fn daily_advances_one_day() {
let rule = Rule::Daily;
let anchor = date("2026-07-01");
assert_eq!(rule.first_on_or_after(date("2026-07-30"), anchor), date("2026-07-30"));
assert_eq!(rule.next_after(date("2026-07-30"), anchor), date("2026-07-31"));
}
#[test]
fn weekly_picks_the_next_listed_weekday() {
let rule: Rule = "weekly:mon,thu".parse().unwrap();
let anchor = date("2026-07-01");
assert_eq!(rule.first_on_or_after(date("2026-07-30"), anchor), date("2026-07-30"));
assert_eq!(rule.next_after(date("2026-07-30"), anchor), date("2026-08-03"));
assert_eq!(rule.next_after(date("2026-08-03"), anchor), date("2026-08-06"));
}
#[test]
fn monthly_clamps_to_the_last_day_of_short_months() {
let rule: Rule = "monthly:31".parse().unwrap();
let anchor = date("2026-01-01");
assert_eq!(rule.next_after(date("2026-01-31"), anchor), date("2026-02-28"));
assert_eq!(rule.next_after(date("2026-02-28"), anchor), date("2026-03-31"));
let leap: Rule = "monthly:30,31".parse().unwrap();
assert_eq!(leap.first_on_or_after(date("2024-02-01"), anchor), date("2024-02-29"));
}
#[test]
fn monthly_walks_within_the_same_month() {
let rule: Rule = "monthly:1,15".parse().unwrap();
let anchor = date("2026-01-01");
assert_eq!(rule.next_after(date("2026-07-01"), anchor), date("2026-07-15"));
assert_eq!(rule.next_after(date("2026-07-15"), anchor), date("2026-08-01"));
}
#[test]
fn every_n_days_counts_from_the_anchor() {
let rule: Rule = "every:3d".parse().unwrap();
let anchor = date("2026-07-01");
assert_eq!(rule.first_on_or_after(date("2026-06-20"), anchor), anchor);
assert_eq!(rule.first_on_or_after(anchor, anchor), anchor);
assert_eq!(rule.first_on_or_after(date("2026-07-02"), anchor), date("2026-07-04"));
assert_eq!(rule.next_after(date("2026-07-04"), anchor), date("2026-07-07"));
}
#[test]
fn new_rule_starts_on_the_first_matching_day() {
let meta = RuleMeta::new("周报".into(), "weekly:mon".parse().unwrap(), date("2026-07-30"));
assert_eq!(meta.next_run, date("2026-08-03"));
let meta = RuleMeta::new("每日".into(), Rule::Daily, date("2026-07-30"));
assert_eq!(meta.next_run, date("2026-07-30"));
}
#[test]
fn exhausted_covers_both_bounds() {
let mut meta = RuleMeta::new("t".into(), Rule::Daily, date("2026-07-01"));
assert!(!meta.exhausted());
meta.until = Some(date("2026-06-30"));
assert!(meta.exhausted());
meta.until = None;
meta.max_count = Some(2);
meta.created_count = 2;
assert!(meta.exhausted());
}
#[test]
fn rule_file_round_trip() {
let mut meta = RuleMeta::new("每周周报".into(), "weekly:mon".parse().unwrap(), date("2026-07-01"));
meta.tags = vec!["weekly".into()];
meta.until = Some(date("2026-12-31"));
meta.max_count = Some(10);
let body = "- [ ] 汇总本周进展\n";
let rendered = render_rule(&meta, body).unwrap();
let (parsed, parsed_body) = parse_rule(&rendered).unwrap();
assert_eq!(parsed.id, meta.id);
assert_eq!(parsed.rule, meta.rule);
assert_eq!(parsed.next_run, meta.next_run);
assert_eq!(parsed.until, meta.until);
assert_eq!(parsed.max_count, meta.max_count);
assert_eq!(parsed.tags, meta.tags);
assert_eq!(parsed_body, body);
}
#[test]
fn rule_file_rejects_bad_rule_and_bounds() {
let meta = RuleMeta::new("t".into(), Rule::Daily, date("2026-07-01"));
let broken = render_rule(&meta, "").unwrap().replace("rule: daily", "rule: hourly");
assert!(parse_rule(&broken).is_err());
let mut bad = meta.clone();
bad.until = Some(date("2026-06-01"));
assert!(parse_rule(&render_rule(&bad, "").unwrap()).is_err());
}
#[test]
fn due_at_is_end_of_the_local_day() {
let due = due_at(date("2026-07-30"));
let local = due.with_timezone(&Local);
assert_eq!(local.date_naive(), date("2026-07-30"));
assert_eq!(local.format("%H:%M:%S").to_string(), "23:59:59");
}
}