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 {
57 return Ok(false);
58 };
59 Ok(self.next_after(last_fired)? <= now)
60 }
61
62 pub fn mark_fired(&mut self, when: DateTime<Utc>) {
63 self.last_fired = Some(when.to_rfc3339());
64 }
65
66 fn schedule_handle(&self) -> crate::Result<Cron> {
67 Cron::from_str(&self.schedule)
68 .map_err(|e| crate::anyhow!("invalid schedule {:?}: {e}", self.schedule))
69 }
70}
71
72pub fn load() -> crate::Result<CronFile> {
73 load_from(&cron_file_path())
74}
75
76pub fn load_from(path: &Path) -> crate::Result<CronFile> {
77 let raw = match fs::read_to_string(path) {
78 Ok(raw) => raw,
79 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(CronFile::default()),
80 Err(e) => return Err(e.into()),
81 };
82 let file: CronFile =
83 toml::from_str(&raw).map_err(|e| crate::anyhow!("parse {}: {e}", path.display()))?;
84 for entry in &file.entries {
85 entry.validate()?;
86 let _ = entry.last_fired_at()?;
87 }
88 Ok(file)
89}
90
91pub fn save(file: &CronFile) -> crate::Result<()> {
92 save_to(&cron_file_path(), file)
93}
94
95pub fn save_to(path: &Path, file: &CronFile) -> crate::Result<()> {
96 for entry in &file.entries {
97 entry.validate()?;
98 let _ = entry.last_fired_at()?;
99 }
100 if let Some(parent) = path.parent() {
101 fs::create_dir_all(parent)?;
102 }
103 let tmp = path.with_extension("toml.tmp");
104 let body =
105 toml::to_string_pretty(file).map_err(|e| crate::anyhow!("serialize cron.toml: {e}"))?;
106 fs::write(&tmp, body)?;
107 fs::rename(&tmp, path)?;
108 Ok(())
109}
110
111fn parse_ts(raw: &str) -> crate::Result<DateTime<Utc>> {
112 Ok(DateTime::parse_from_rfc3339(raw)
113 .map_err(|e| crate::anyhow!("invalid last_fired {:?}: {e}", raw))?
114 .with_timezone(&Utc))
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use chrono::TimeZone;
121 use tempfile::tempdir;
122
123 fn sample_entry() -> CronEntry {
124 CronEntry {
125 label: "morning-brief".to_string(),
126 schedule: "0 7 * * *".to_string(),
127 target: "agent0".to_string(),
128 prompt: "/morning-brief".to_string(),
129 last_fired: None,
130 }
131 }
132
133 #[test]
134 fn validates_five_field_schedule() {
135 sample_entry().validate().unwrap();
136 }
137
138 #[test]
139 fn next_fire_advances_from_last_fired() {
140 let mut entry = sample_entry();
141 entry.last_fired = Some("2026-04-15T07:00:00Z".to_string());
142 let now = Utc.with_ymd_and_hms(2026, 4, 17, 6, 0, 0).unwrap();
143 assert_eq!(
144 entry.next_fire(now).unwrap(),
145 Utc.with_ymd_and_hms(2026, 4, 16, 7, 0, 0).unwrap()
146 );
147 assert!(
148 entry
149 .is_due(Utc.with_ymd_and_hms(2026, 4, 17, 6, 0, 0).unwrap())
150 .unwrap()
151 );
152 }
153
154 #[test]
155 fn save_and_load_round_trip() {
156 let dir = tempdir().unwrap();
157 let path = dir.path().join("cron.toml");
158 let mut file = CronFile {
159 entries: vec![sample_entry()],
160 };
161 file.entries[0].mark_fired(Utc.with_ymd_and_hms(2026, 4, 17, 7, 0, 0).unwrap());
162 save_to(&path, &file).unwrap();
163 let loaded = load_from(&path).unwrap();
164 assert_eq!(loaded.entries.len(), 1);
165 assert_eq!(loaded.entries[0].label, "morning-brief");
166 assert_eq!(loaded.entries[0].last_fired, file.entries[0].last_fired);
167 }
168}