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        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}