use std::fs;
use std::path::Path;
use std::str::FromStr;
use chrono::{DateTime, Utc};
use croner::Cron;
use serde::{Deserialize, Serialize};
use crate::paths::cron_file_path;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CronFile {
#[serde(default)]
pub entries: Vec<CronEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronEntry {
pub label: String,
pub schedule: String,
pub target: String,
pub prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_fired: Option<String>,
}
impl CronEntry {
pub fn validate(&self) -> crate::Result<()> {
self.schedule_handle().map(|_| ())
}
pub fn last_fired_at(&self) -> crate::Result<Option<DateTime<Utc>>> {
self.last_fired
.as_deref()
.map(parse_ts)
.transpose()
.map_err(|e| crate::anyhow!("cron {label}: {e}", label = self.label))
}
pub fn next_after(&self, after: DateTime<Utc>) -> crate::Result<DateTime<Utc>> {
self.schedule_handle()?
.find_next_occurrence(&after, false)
.map_err(|e| crate::anyhow!("cron {label}: compute next fire: {e}", label = self.label))
}
pub fn next_fire(&self, now: DateTime<Utc>) -> crate::Result<DateTime<Utc>> {
match self.last_fired_at()? {
Some(ts) => self.next_after(ts),
None => self.next_after(now),
}
}
pub fn is_due(&self, now: DateTime<Utc>) -> crate::Result<bool> {
let Some(last_fired) = self.last_fired_at()? else {
return Ok(false);
};
Ok(self.next_after(last_fired)? <= now)
}
pub fn mark_fired(&mut self, when: DateTime<Utc>) {
self.last_fired = Some(when.to_rfc3339());
}
fn schedule_handle(&self) -> crate::Result<Cron> {
Cron::from_str(&self.schedule)
.map_err(|e| crate::anyhow!("invalid schedule {:?}: {e}", self.schedule))
}
}
pub fn load() -> crate::Result<CronFile> {
load_from(&cron_file_path())
}
pub fn load_from(path: &Path) -> crate::Result<CronFile> {
let raw = match fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(CronFile::default()),
Err(e) => return Err(e.into()),
};
let file: CronFile =
toml::from_str(&raw).map_err(|e| crate::anyhow!("parse {}: {e}", path.display()))?;
for entry in &file.entries {
entry.validate()?;
let _ = entry.last_fired_at()?;
}
Ok(file)
}
pub fn save(file: &CronFile) -> crate::Result<()> {
save_to(&cron_file_path(), file)
}
pub fn save_to(path: &Path, file: &CronFile) -> crate::Result<()> {
for entry in &file.entries {
entry.validate()?;
let _ = entry.last_fired_at()?;
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("toml.tmp");
let body =
toml::to_string_pretty(file).map_err(|e| crate::anyhow!("serialize cron.toml: {e}"))?;
fs::write(&tmp, body)?;
fs::rename(&tmp, path)?;
Ok(())
}
fn parse_ts(raw: &str) -> crate::Result<DateTime<Utc>> {
Ok(DateTime::parse_from_rfc3339(raw)
.map_err(|e| crate::anyhow!("invalid last_fired {:?}: {e}", raw))?
.with_timezone(&Utc))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use tempfile::tempdir;
fn sample_entry() -> CronEntry {
CronEntry {
label: "morning-brief".to_string(),
schedule: "0 7 * * *".to_string(),
target: "agent0".to_string(),
prompt: "/morning-brief".to_string(),
last_fired: None,
}
}
#[test]
fn validates_five_field_schedule() {
sample_entry().validate().unwrap();
}
#[test]
fn next_fire_advances_from_last_fired() {
let mut entry = sample_entry();
entry.last_fired = Some("2026-04-15T07:00:00Z".to_string());
let now = Utc.with_ymd_and_hms(2026, 4, 17, 6, 0, 0).unwrap();
assert_eq!(
entry.next_fire(now).unwrap(),
Utc.with_ymd_and_hms(2026, 4, 16, 7, 0, 0).unwrap()
);
assert!(
entry
.is_due(Utc.with_ymd_and_hms(2026, 4, 17, 6, 0, 0).unwrap())
.unwrap()
);
}
#[test]
fn save_and_load_round_trip() {
let dir = tempdir().unwrap();
let path = dir.path().join("cron.toml");
let mut file = CronFile {
entries: vec![sample_entry()],
};
file.entries[0].mark_fired(Utc.with_ymd_and_hms(2026, 4, 17, 7, 0, 0).unwrap());
save_to(&path, &file).unwrap();
let loaded = load_from(&path).unwrap();
assert_eq!(loaded.entries.len(), 1);
assert_eq!(loaded.entries[0].label, "morning-brief");
assert_eq!(loaded.entries[0].last_fired, file.entries[0].last_fired);
}
}