Skip to main content

netsky_core/
cron.rs

1//! Durable cron entry storage under `~/.netsky/cron.toml`.
2
3use 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        // No prior fire record: treat as due so the cron starts running
57        // on the next tick. Without this, entries added before the add
58        // path started seeding last_fired stay silent forever.
59        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}