Skip to main content

agentzero_tools/
cron_tools.rs

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// ── cron_add ──
13
14#[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// ── cron_list ──
62
63#[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// ── cron_remove ──
108
109#[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// ── cron_update ──
150
151#[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// ── cron_pause ──
206
207#[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// ── cron_resume ──
248
249#[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}