Skip to main content

agentzero_tools/
cron_store.rs

1use agentzero_storage::EncryptedJsonStore;
2use anyhow::{bail, Context};
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6const TASKS_FILE: &str = "cron-tasks.json";
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9pub struct CronTask {
10    pub id: String,
11    pub schedule: String,
12    pub command: String,
13    pub enabled: bool,
14    pub last_run_epoch_seconds: Option<u64>,
15}
16
17#[derive(Debug, Clone)]
18pub struct CronStore {
19    store: EncryptedJsonStore,
20}
21
22impl CronStore {
23    pub fn new(data_dir: impl AsRef<Path>) -> anyhow::Result<Self> {
24        Ok(Self {
25            store: EncryptedJsonStore::in_config_dir(data_dir.as_ref(), TASKS_FILE)?,
26        })
27    }
28
29    pub fn list(&self) -> anyhow::Result<Vec<CronTask>> {
30        self.store.load_or_default()
31    }
32
33    pub fn add(&self, id: &str, schedule: &str, command: &str) -> anyhow::Result<CronTask> {
34        let mut tasks = self.list()?;
35        if tasks.iter().any(|task| task.id == id) {
36            bail!("task `{id}` already exists");
37        }
38        if id.trim().is_empty() || schedule.trim().is_empty() || command.trim().is_empty() {
39            bail!("id, schedule, and command must be non-empty");
40        }
41        let task = CronTask {
42            id: id.to_string(),
43            schedule: schedule.to_string(),
44            command: command.to_string(),
45            enabled: true,
46            last_run_epoch_seconds: None,
47        };
48        tasks.push(task.clone());
49        self.store.save(&tasks)?;
50        Ok(task)
51    }
52
53    pub fn update(
54        &self,
55        id: &str,
56        schedule: Option<&str>,
57        command: Option<&str>,
58    ) -> anyhow::Result<CronTask> {
59        let mut tasks = self.list()?;
60        let task = tasks
61            .iter_mut()
62            .find(|task| task.id == id)
63            .with_context(|| format!("task `{id}` not found"))?;
64
65        if let Some(schedule) = schedule {
66            if schedule.trim().is_empty() {
67                bail!("schedule must be non-empty when provided");
68            }
69            task.schedule = schedule.to_string();
70        }
71
72        if let Some(command) = command {
73            if command.trim().is_empty() {
74                bail!("command must be non-empty when provided");
75            }
76            task.command = command.to_string();
77        }
78
79        let updated = task.clone();
80        self.store.save(&tasks)?;
81        Ok(updated)
82    }
83
84    pub fn pause(&self, id: &str) -> anyhow::Result<CronTask> {
85        self.set_enabled(id, false)
86    }
87
88    pub fn resume(&self, id: &str) -> anyhow::Result<CronTask> {
89        self.set_enabled(id, true)
90    }
91
92    pub fn remove(&self, id: &str) -> anyhow::Result<()> {
93        let mut tasks = self.list()?;
94        let before = tasks.len();
95        tasks.retain(|task| task.id != id);
96        if tasks.len() == before {
97            bail!("task `{id}` not found");
98        }
99        self.store.save(&tasks)?;
100        Ok(())
101    }
102
103    fn set_enabled(&self, id: &str, enabled: bool) -> anyhow::Result<CronTask> {
104        let mut tasks = self.list()?;
105        let task = tasks
106            .iter_mut()
107            .find(|task| task.id == id)
108            .with_context(|| format!("task `{id}` not found"))?;
109        task.enabled = enabled;
110        let updated = task.clone();
111        self.store.save(&tasks)?;
112        Ok(updated)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::CronStore;
119    use std::fs;
120    use std::sync::atomic::{AtomicU64, Ordering};
121    use std::time::{SystemTime, UNIX_EPOCH};
122
123    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
124
125    fn temp_dir() -> std::path::PathBuf {
126        let nanos = SystemTime::now()
127            .duration_since(UNIX_EPOCH)
128            .expect("time should move forward")
129            .as_nanos();
130        let seq = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
131        let dir = std::env::temp_dir().join(format!(
132            "agentzero-cron-test-{}-{nanos}-{seq}",
133            std::process::id()
134        ));
135        fs::create_dir_all(&dir).expect("temp dir should be created");
136        dir
137    }
138
139    #[test]
140    fn add_update_pause_resume_remove_success_path() {
141        let dir = temp_dir();
142        let store = CronStore::new(&dir).expect("store should create");
143
144        let task = store
145            .add("backup", "0 * * * *", "agentzero status")
146            .expect("add should succeed");
147        assert!(task.enabled);
148
149        let updated = store
150            .update("backup", Some("*/5 * * * *"), None)
151            .expect("update should succeed");
152        assert_eq!(updated.schedule, "*/5 * * * *");
153
154        let paused = store.pause("backup").expect("pause should succeed");
155        assert!(!paused.enabled);
156
157        let resumed = store.resume("backup").expect("resume should succeed");
158        assert!(resumed.enabled);
159
160        store.remove("backup").expect("remove should succeed");
161        assert!(store.list().expect("list should succeed").is_empty());
162
163        fs::remove_dir_all(dir).expect("temp dir should be removed");
164    }
165
166    #[test]
167    fn add_duplicate_id_fails_negative_path() {
168        let dir = temp_dir();
169        let store = CronStore::new(&dir).expect("store should create");
170        store
171            .add("backup", "0 * * * *", "agentzero status")
172            .expect("first add should succeed");
173        let err = store
174            .add("backup", "0 * * * *", "agentzero status")
175            .expect_err("duplicate add should fail");
176        assert!(err.to_string().contains("already exists"));
177        fs::remove_dir_all(dir).expect("temp dir should be removed");
178    }
179
180    #[test]
181    fn persistence_round_trip() {
182        let dir = temp_dir();
183        {
184            let store = CronStore::new(&dir).expect("store");
185            store.add("job-a", "0 * * * *", "cmd-a").expect("add");
186            store.add("job-b", "*/5 * * * *", "cmd-b").expect("add");
187        }
188        // Reopen store from same dir — tasks should persist.
189        let store = CronStore::new(&dir).expect("reopen");
190        let tasks = store.list().expect("list");
191        assert_eq!(tasks.len(), 2);
192        fs::remove_dir_all(dir).ok();
193    }
194
195    #[test]
196    fn remove_nonexistent_fails() {
197        let dir = temp_dir();
198        let store = CronStore::new(&dir).expect("store");
199        let err = store.remove("ghost").expect_err("should fail");
200        assert!(err.to_string().contains("not found"));
201        fs::remove_dir_all(dir).ok();
202    }
203
204    #[test]
205    fn list_empty_returns_empty() {
206        let dir = temp_dir();
207        let store = CronStore::new(&dir).expect("store");
208        let tasks = store.list().expect("list");
209        assert!(tasks.is_empty());
210        fs::remove_dir_all(dir).ok();
211    }
212}