1use crate::cron_store::CronStore;
2use agentzero_core::{Tool, ToolContext, ToolResult};
3use anyhow::{anyhow, Context};
4use async_trait::async_trait;
5use serde::Deserialize;
6use std::path::PathBuf;
7
8fn cron_data_dir(workspace_root: &str) -> PathBuf {
9 PathBuf::from(workspace_root).join(".agentzero")
10}
11
12#[derive(Debug, Deserialize)]
15struct CronAddInput {
16 id: String,
17 schedule: String,
18 command: String,
19}
20
21#[derive(Debug, Default, Clone, Copy)]
22pub struct CronAddTool;
23
24#[async_trait]
25impl Tool for CronAddTool {
26 fn name(&self) -> &'static str {
27 "cron_add"
28 }
29
30 fn description(&self) -> &'static str {
31 "Add a new cron task with a schedule and command."
32 }
33
34 fn input_schema(&self) -> Option<serde_json::Value> {
35 Some(serde_json::json!({
36 "type": "object",
37 "properties": {
38 "id": { "type": "string", "description": "Unique task ID" },
39 "schedule": { "type": "string", "description": "Cron expression (e.g. '0 * * * *')" },
40 "command": { "type": "string", "description": "Command to execute" }
41 },
42 "required": ["id", "schedule", "command"],
43 "additionalProperties": false
44 }))
45 }
46
47 async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
48 let req: CronAddInput = serde_json::from_str(input)
49 .context("cron_add expects JSON: {\"id\", \"schedule\", \"command\"}")?;
50 let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
51 let task = store.add(&req.id, &req.schedule, &req.command)?;
52 Ok(ToolResult {
53 output: format!(
54 "added cron task: id={}, schedule={}, command={}, enabled={}",
55 task.id, task.schedule, task.command, task.enabled
56 ),
57 })
58 }
59}
60
61#[derive(Debug, Default, Clone, Copy)]
64pub struct CronListTool;
65
66#[async_trait]
67impl Tool for CronListTool {
68 fn name(&self) -> &'static str {
69 "cron_list"
70 }
71
72 fn description(&self) -> &'static str {
73 "List all registered cron tasks."
74 }
75
76 fn input_schema(&self) -> Option<serde_json::Value> {
77 Some(serde_json::json!({
78 "type": "object",
79 "properties": {},
80 "additionalProperties": false
81 }))
82 }
83
84 async fn execute(&self, _input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
85 let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
86 let tasks = store.list()?;
87 if tasks.is_empty() {
88 return Ok(ToolResult {
89 output: "no cron tasks".to_string(),
90 });
91 }
92 let lines: Vec<String> = tasks
93 .iter()
94 .map(|t| {
95 format!(
96 "id={} schedule={} command={} enabled={}",
97 t.id, t.schedule, t.command, t.enabled
98 )
99 })
100 .collect();
101 Ok(ToolResult {
102 output: lines.join("\n"),
103 })
104 }
105}
106
107#[derive(Debug, Deserialize)]
110struct CronRemoveInput {
111 id: String,
112}
113
114#[derive(Debug, Default, Clone, Copy)]
115pub struct CronRemoveTool;
116
117#[async_trait]
118impl Tool for CronRemoveTool {
119 fn name(&self) -> &'static str {
120 "cron_remove"
121 }
122
123 fn description(&self) -> &'static str {
124 "Remove a cron task by ID."
125 }
126
127 fn input_schema(&self) -> Option<serde_json::Value> {
128 Some(serde_json::json!({
129 "type": "object",
130 "properties": {
131 "id": { "type": "string", "description": "ID of the cron task to remove" }
132 },
133 "required": ["id"],
134 "additionalProperties": false
135 }))
136 }
137
138 async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
139 let req: CronRemoveInput =
140 serde_json::from_str(input).context("cron_remove expects JSON: {\"id\": \"...\"}")?;
141 let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
142 store.remove(&req.id)?;
143 Ok(ToolResult {
144 output: format!("removed cron task: {}", req.id),
145 })
146 }
147}
148
149#[derive(Debug, Deserialize)]
152struct CronUpdateInput {
153 id: String,
154 #[serde(default)]
155 schedule: Option<String>,
156 #[serde(default)]
157 command: Option<String>,
158}
159
160#[derive(Debug, Default, Clone, Copy)]
161pub struct CronUpdateTool;
162
163#[async_trait]
164impl Tool for CronUpdateTool {
165 fn name(&self) -> &'static str {
166 "cron_update"
167 }
168
169 fn description(&self) -> &'static str {
170 "Update an existing cron task's schedule or command."
171 }
172
173 fn input_schema(&self) -> Option<serde_json::Value> {
174 Some(serde_json::json!({
175 "type": "object",
176 "properties": {
177 "id": { "type": "string", "description": "ID of the cron task to update" },
178 "schedule": { "type": "string", "description": "New cron schedule expression" },
179 "command": { "type": "string", "description": "New command to execute" }
180 },
181 "required": ["id"],
182 "additionalProperties": false
183 }))
184 }
185
186 async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
187 let req: CronUpdateInput =
188 serde_json::from_str(input).context("cron_update expects JSON: {\"id\", ...}")?;
189 if req.schedule.is_none() && req.command.is_none() {
190 return Err(anyhow!(
191 "at least one of schedule or command must be provided"
192 ));
193 }
194 let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
195 let task = store.update(&req.id, req.schedule.as_deref(), req.command.as_deref())?;
196 Ok(ToolResult {
197 output: format!(
198 "updated cron task: id={}, schedule={}, command={}",
199 task.id, task.schedule, task.command
200 ),
201 })
202 }
203}
204
205#[derive(Debug, Deserialize)]
208struct CronPauseInput {
209 id: String,
210}
211
212#[derive(Debug, Default, Clone, Copy)]
213pub struct CronPauseTool;
214
215#[async_trait]
216impl Tool for CronPauseTool {
217 fn name(&self) -> &'static str {
218 "cron_pause"
219 }
220
221 fn description(&self) -> &'static str {
222 "Pause a cron task (disable without removing)."
223 }
224
225 fn input_schema(&self) -> Option<serde_json::Value> {
226 Some(serde_json::json!({
227 "type": "object",
228 "properties": {
229 "id": { "type": "string", "description": "ID of the cron task to pause" }
230 },
231 "required": ["id"],
232 "additionalProperties": false
233 }))
234 }
235
236 async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
237 let req: CronPauseInput =
238 serde_json::from_str(input).context("cron_pause expects JSON: {\"id\": \"...\"}")?;
239 let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
240 let task = store.pause(&req.id)?;
241 Ok(ToolResult {
242 output: format!("paused cron task: id={}, enabled={}", task.id, task.enabled),
243 })
244 }
245}
246
247#[derive(Debug, Deserialize)]
250struct CronResumeInput {
251 id: String,
252}
253
254#[derive(Debug, Default, Clone, Copy)]
255pub struct CronResumeTool;
256
257#[async_trait]
258impl Tool for CronResumeTool {
259 fn name(&self) -> &'static str {
260 "cron_resume"
261 }
262
263 fn description(&self) -> &'static str {
264 "Resume a paused cron task."
265 }
266
267 fn input_schema(&self) -> Option<serde_json::Value> {
268 Some(serde_json::json!({
269 "type": "object",
270 "properties": {
271 "id": { "type": "string", "description": "ID of the cron task to resume" }
272 },
273 "required": ["id"],
274 "additionalProperties": false
275 }))
276 }
277
278 async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
279 let req: CronResumeInput =
280 serde_json::from_str(input).context("cron_resume expects JSON: {\"id\": \"...\"}")?;
281 let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
282 let task = store.resume(&req.id)?;
283 Ok(ToolResult {
284 output: format!(
285 "resumed cron task: id={}, enabled={}",
286 task.id, task.enabled
287 ),
288 })
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use std::fs;
296 use std::sync::atomic::{AtomicU64, Ordering};
297 use std::time::{SystemTime, UNIX_EPOCH};
298
299 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
300
301 fn temp_dir() -> PathBuf {
302 let nanos = SystemTime::now()
303 .duration_since(UNIX_EPOCH)
304 .expect("clock")
305 .as_nanos();
306 let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
307 let dir = std::env::temp_dir().join(format!(
308 "agentzero-cron-tools-{}-{nanos}-{seq}",
309 std::process::id()
310 ));
311 fs::create_dir_all(&dir).expect("temp dir should be created");
312 dir
313 }
314
315 #[tokio::test]
316 async fn cron_add_list_remove_roundtrip() {
317 let dir = temp_dir();
318 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
319
320 let add = CronAddTool;
321 let result = add
322 .execute(
323 r#"{"id": "backup", "schedule": "0 * * * *", "command": "echo hello"}"#,
324 &ctx,
325 )
326 .await
327 .expect("add should succeed");
328 assert!(result.output.contains("backup"));
329
330 let list = CronListTool;
331 let result = list.execute("{}", &ctx).await.expect("list should succeed");
332 assert!(result.output.contains("backup"));
333
334 let remove = CronRemoveTool;
335 let result = remove
336 .execute(r#"{"id": "backup"}"#, &ctx)
337 .await
338 .expect("remove should succeed");
339 assert!(result.output.contains("removed"));
340
341 let result = list.execute("{}", &ctx).await.expect("list should succeed");
342 assert!(result.output.contains("no cron tasks"));
343 fs::remove_dir_all(dir).ok();
344 }
345
346 #[tokio::test]
347 async fn cron_update_changes_schedule() {
348 let dir = temp_dir();
349 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
350
351 let add = CronAddTool;
352 add.execute(
353 r#"{"id": "test", "schedule": "0 * * * *", "command": "echo"}"#,
354 &ctx,
355 )
356 .await
357 .expect("add should succeed");
358
359 let update = CronUpdateTool;
360 let result = update
361 .execute(r#"{"id": "test", "schedule": "*/5 * * * *"}"#, &ctx)
362 .await
363 .expect("update should succeed");
364 assert!(result.output.contains("*/5 * * * *"));
365 fs::remove_dir_all(dir).ok();
366 }
367
368 #[tokio::test]
369 async fn cron_pause_resume() {
370 let dir = temp_dir();
371 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
372
373 let add = CronAddTool;
374 add.execute(
375 r#"{"id": "job", "schedule": "0 * * * *", "command": "test"}"#,
376 &ctx,
377 )
378 .await
379 .expect("add should succeed");
380
381 let pause = CronPauseTool;
382 let result = pause
383 .execute(r#"{"id": "job"}"#, &ctx)
384 .await
385 .expect("pause should succeed");
386 assert!(result.output.contains("enabled=false"));
387
388 let resume = CronResumeTool;
389 let result = resume
390 .execute(r#"{"id": "job"}"#, &ctx)
391 .await
392 .expect("resume should succeed");
393 assert!(result.output.contains("enabled=true"));
394 fs::remove_dir_all(dir).ok();
395 }
396
397 #[tokio::test]
398 async fn cron_remove_nonexistent_fails() {
399 let dir = temp_dir();
400 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
401 let remove = CronRemoveTool;
402 let err = remove
403 .execute(r#"{"id": "nope"}"#, &ctx)
404 .await
405 .expect_err("removing nonexistent should fail");
406 assert!(err.to_string().contains("not found"));
407 fs::remove_dir_all(dir).ok();
408 }
409}