agentzero_tools/
cron_store.rs1use 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 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}