1use std::fs;
4use std::path::Path;
5use std::str::FromStr;
6
7use chrono::{DateTime, Utc};
8use croner::Cron;
9use serde::{Deserialize, Serialize};
10
11use crate::paths::cron_file_path;
12
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14pub struct CronFile {
15 #[serde(default)]
16 pub entries: Vec<CronEntry>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct CronEntry {
21 pub label: String,
22 pub schedule: String,
23 pub target: String,
24 pub prompt: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub last_fired: Option<String>,
27}
28
29impl CronEntry {
30 pub fn validate(&self) -> crate::Result<()> {
31 self.schedule_handle().map(|_| ())
32 }
33
34 pub fn last_fired_at(&self) -> crate::Result<Option<DateTime<Utc>>> {
35 self.last_fired
36 .as_deref()
37 .map(parse_ts)
38 .transpose()
39 .map_err(|e| crate::anyhow!("cron {label}: {e}", label = self.label))
40 }
41
42 pub fn next_after(&self, after: DateTime<Utc>) -> crate::Result<DateTime<Utc>> {
43 self.schedule_handle()?
44 .find_next_occurrence(&after, false)
45 .map_err(|e| crate::anyhow!("cron {label}: compute next fire: {e}", label = self.label))
46 }
47
48 pub fn next_fire(&self, now: DateTime<Utc>) -> crate::Result<DateTime<Utc>> {
49 match self.last_fired_at()? {
50 Some(ts) => self.next_after(ts),
51 None => self.next_after(now),
52 }
53 }
54
55 pub fn is_due(&self, now: DateTime<Utc>) -> crate::Result<bool> {
56 let Some(last_fired) = self.last_fired_at()? else {
60 return Ok(true);
61 };
62 Ok(self.next_after(last_fired)? <= now)
63 }
64
65 pub fn mark_fired(&mut self, when: DateTime<Utc>) {
66 self.last_fired = Some(when.to_rfc3339());
67 }
68
69 fn schedule_handle(&self) -> crate::Result<Cron> {
70 Cron::from_str(&self.schedule)
71 .map_err(|e| crate::anyhow!("invalid schedule {:?}: {e}", self.schedule))
72 }
73}
74
75pub fn load() -> crate::Result<CronFile> {
76 load_from(&cron_file_path())
77}
78
79pub fn load_from(path: &Path) -> crate::Result<CronFile> {
80 let raw = match fs::read_to_string(path) {
81 Ok(raw) => raw,
82 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(CronFile::default()),
83 Err(e) => return Err(e.into()),
84 };
85 let file: CronFile =
86 toml::from_str(&raw).map_err(|e| crate::anyhow!("parse {}: {e}", path.display()))?;
87 for entry in &file.entries {
88 entry.validate()?;
89 let _ = entry.last_fired_at()?;
90 }
91 Ok(file)
92}
93
94pub fn save(file: &CronFile) -> crate::Result<()> {
95 save_to(&cron_file_path(), file)
96}
97
98pub fn save_to(path: &Path, file: &CronFile) -> crate::Result<()> {
99 for entry in &file.entries {
100 entry.validate()?;
101 let _ = entry.last_fired_at()?;
102 }
103 if let Some(parent) = path.parent() {
104 fs::create_dir_all(parent)?;
105 }
106 let tmp = path.with_extension("toml.tmp");
107 let body =
108 toml::to_string_pretty(file).map_err(|e| crate::anyhow!("serialize cron.toml: {e}"))?;
109 fs::write(&tmp, body)?;
110 fs::rename(&tmp, path)?;
111 Ok(())
112}
113
114fn parse_ts(raw: &str) -> crate::Result<DateTime<Utc>> {
115 Ok(DateTime::parse_from_rfc3339(raw)
116 .map_err(|e| crate::anyhow!("invalid last_fired {:?}: {e}", raw))?
117 .with_timezone(&Utc))
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use chrono::TimeZone;
124 use tempfile::tempdir;
125
126 fn sample_entry() -> CronEntry {
127 CronEntry {
128 label: "morning-brief".to_string(),
129 schedule: "0 7 * * *".to_string(),
130 target: "agent0".to_string(),
131 prompt: "/morning-brief".to_string(),
132 last_fired: None,
133 }
134 }
135
136 #[test]
137 fn validates_five_field_schedule() {
138 sample_entry().validate().unwrap();
139 }
140
141 #[test]
142 fn next_fire_advances_from_last_fired() {
143 let mut entry = sample_entry();
144 entry.last_fired = Some("2026-04-15T07:00:00Z".to_string());
145 let now = Utc.with_ymd_and_hms(2026, 4, 17, 6, 0, 0).unwrap();
146 assert_eq!(
147 entry.next_fire(now).unwrap(),
148 Utc.with_ymd_and_hms(2026, 4, 16, 7, 0, 0).unwrap()
149 );
150 assert!(
151 entry
152 .is_due(Utc.with_ymd_and_hms(2026, 4, 17, 6, 0, 0).unwrap())
153 .unwrap()
154 );
155 }
156
157 #[test]
158 fn is_due_with_no_last_fired_is_true() {
159 let entry = sample_entry();
160 assert!(entry.last_fired.is_none());
161 assert!(
162 entry
163 .is_due(Utc.with_ymd_and_hms(2026, 4, 19, 18, 22, 0).unwrap())
164 .unwrap(),
165 "a fresh entry with no last_fired record should fire on the next tick"
166 );
167 }
168
169 #[test]
170 fn save_and_load_round_trip() {
171 let dir = tempdir().unwrap();
172 let path = dir.path().join("cron.toml");
173 let mut file = CronFile {
174 entries: vec![sample_entry()],
175 };
176 file.entries[0].mark_fired(Utc.with_ymd_and_hms(2026, 4, 17, 7, 0, 0).unwrap());
177 save_to(&path, &file).unwrap();
178 let loaded = load_from(&path).unwrap();
179 assert_eq!(loaded.entries.len(), 1);
180 assert_eq!(loaded.entries[0].label, "morning-brief");
181 assert_eq!(loaded.entries[0].last_fired, file.entries[0].last_fired);
182 }
183}