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
12pub(crate) fn parse_natural_schedule(input: &str) -> Option<String> {
30 let s = input.trim().to_ascii_lowercase();
31
32 if looks_like_cron(&s) {
34 return None;
35 }
36
37 if let Some(n) = extract_every_n_minutes(&s) {
39 return Some(format!("*/{n} * * * *"));
40 }
41
42 if let Some(n) = extract_every_n_hours(&s) {
44 return Some(format!("0 */{n} * * *"));
45 }
46
47 if s == "every minute" {
49 return Some("* * * * *".to_string());
50 }
51
52 if s == "every hour" || s == "hourly" {
54 return Some("0 * * * *".to_string());
55 }
56
57 if s == "every day" || s == "daily" {
59 return Some("0 0 * * *".to_string());
60 }
61
62 if let Some(cron) = parse_daily_at(&s) {
64 return Some(cron);
65 }
66
67 if s == "weekly" || s == "every week" {
69 return Some("0 0 * * 0".to_string());
70 }
71
72 if let Some(cron) = parse_weekly_on(&s) {
74 return Some(cron);
75 }
76
77 if s == "monthly" || s == "every month" {
79 return Some("0 0 1 * *".to_string());
80 }
81
82 None
83}
84
85fn looks_like_cron(s: &str) -> bool {
86 let parts: Vec<&str> = s.split_whitespace().collect();
87 parts.len() == 5 && parts[0].starts_with(|c: char| c.is_ascii_digit() || c == '*')
88}
89
90fn extract_every_n_minutes(s: &str) -> Option<u32> {
91 let s = s.strip_prefix("every ")?;
93 let rest = s
94 .strip_suffix(" minutes")
95 .or_else(|| s.strip_suffix(" min"))?;
96 let n: u32 = rest.trim().parse().ok()?;
97 if n > 0 && n <= 59 {
98 Some(n)
99 } else {
100 None
101 }
102}
103
104fn extract_every_n_hours(s: &str) -> Option<u32> {
105 let s = s.strip_prefix("every ")?;
107 let rest = s
108 .strip_suffix(" hours")
109 .or_else(|| s.strip_suffix(" hrs"))
110 .or_else(|| s.strip_suffix(" hour"))?;
111 let n: u32 = rest.trim().parse().ok()?;
112 if n > 0 && n <= 23 {
113 Some(n)
114 } else {
115 None
116 }
117}
118
119fn parse_daily_at(s: &str) -> Option<String> {
120 let time_str = s
125 .strip_prefix("daily at ")
126 .or_else(|| s.strip_prefix("every day at "))?;
127 let (hour, minute) = parse_time(time_str)?;
128 Some(format!("{minute} {hour} * * *"))
129}
130
131fn parse_weekly_on(s: &str) -> Option<String> {
132 let day_str = s
135 .strip_prefix("weekly on ")
136 .or_else(|| s.strip_prefix("every "))?;
137 let dow = day_of_week(day_str.trim())?;
138 Some(format!("0 0 * * {dow}"))
139}
140
141fn parse_time(s: &str) -> Option<(u32, u32)> {
142 let s = s.trim();
143
144 if let Some(rest) = s.strip_suffix("pm") {
146 return parse_hm(rest, true);
147 }
148 if let Some(rest) = s.strip_suffix("am") {
149 return parse_hm(rest, false);
150 }
151
152 if s.contains(':') {
154 let parts: Vec<&str> = s.split(':').collect();
155 if parts.len() == 2 {
156 let h: u32 = parts[0].parse().ok()?;
157 let m: u32 = parts[1].parse().ok()?;
158 if h < 24 && m < 60 {
159 return Some((h, m));
160 }
161 }
162 }
163
164 if let Ok(h) = s.parse::<u32>() {
166 if h < 24 {
167 return Some((h, 0));
168 }
169 }
170
171 None
172}
173
174fn parse_hm(s: &str, is_pm: bool) -> Option<(u32, u32)> {
175 let s = s.trim();
176 if s.contains(':') {
177 let parts: Vec<&str> = s.split(':').collect();
178 if parts.len() == 2 {
179 let mut h: u32 = parts[0].parse().ok()?;
180 let m: u32 = parts[1].parse().ok()?;
181 if is_pm && h < 12 {
182 h += 12;
183 }
184 if !is_pm && h == 12 {
185 h = 0;
186 }
187 if h < 24 && m < 60 {
188 return Some((h, m));
189 }
190 }
191 } else {
192 let mut h: u32 = s.parse().ok()?;
193 if is_pm && h < 12 {
194 h += 12;
195 }
196 if !is_pm && h == 12 {
197 h = 0;
198 }
199 if h < 24 {
200 return Some((h, 0));
201 }
202 }
203 None
204}
205
206fn day_of_week(s: &str) -> Option<u8> {
207 match s {
208 "sunday" | "sun" => Some(0),
209 "monday" | "mon" => Some(1),
210 "tuesday" | "tue" | "tues" => Some(2),
211 "wednesday" | "wed" => Some(3),
212 "thursday" | "thu" | "thurs" => Some(4),
213 "friday" | "fri" => Some(5),
214 "saturday" | "sat" => Some(6),
215 _ => None,
216 }
217}
218
219#[derive(Debug, Deserialize)]
224struct ScheduleInput {
225 action: String,
226 #[serde(default)]
227 id: Option<String>,
228 #[serde(default)]
229 schedule: Option<String>,
230 #[serde(default)]
231 command: Option<String>,
232}
233
234#[derive(Debug, Default, Clone, Copy)]
245pub struct ScheduleTool;
246
247#[async_trait]
248impl Tool for ScheduleTool {
249 fn name(&self) -> &'static str {
250 "schedule"
251 }
252
253 fn description(&self) -> &'static str {
254 "Manage scheduled tasks: create, list, update, remove, pause, resume, or parse cron expressions."
255 }
256
257 fn input_schema(&self) -> Option<serde_json::Value> {
258 Some(serde_json::json!({
259 "type": "object",
260 "properties": {
261 "action": { "type": "string", "enum": ["create", "list", "update", "remove", "pause", "resume", "parse"], "description": "The scheduling action to perform" },
262 "id": { "type": "string", "description": "Task ID (required for create/update/remove/pause/resume)" },
263 "schedule": { "type": "string", "description": "Cron expression or natural language schedule (e.g. 'every 5 minutes')" },
264 "command": { "type": "string", "description": "Command to run on schedule" }
265 },
266 "required": ["action"],
267 "additionalProperties": false
268 }))
269 }
270
271 async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
272 let req: ScheduleInput = serde_json::from_str(input)
273 .context("schedule expects JSON: {\"action\": \"create|list|update|remove|pause|resume|parse\", ...}")?;
274
275 match req.action.as_str() {
276 "create" => handle_create(ctx, &req),
277 "list" => handle_list(ctx),
278 "update" => handle_update(ctx, &req),
279 "remove" => handle_remove(ctx, &req),
280 "pause" => handle_pause(ctx, &req),
281 "resume" => handle_resume(ctx, &req),
282 "parse" => handle_parse(&req),
283 other => Err(anyhow!(
284 "unknown action `{other}`: expected create, list, update, remove, pause, resume, or parse"
285 )),
286 }
287 }
288}
289
290fn require_id(req: &ScheduleInput) -> anyhow::Result<&str> {
291 req.id
292 .as_deref()
293 .ok_or_else(|| anyhow!("missing required field `id`"))
294}
295
296fn resolve_schedule(raw: &str) -> String {
297 parse_natural_schedule(raw).unwrap_or_else(|| raw.to_string())
298}
299
300fn handle_create(ctx: &ToolContext, req: &ScheduleInput) -> anyhow::Result<ToolResult> {
301 let id = require_id(req)?;
302 let raw_schedule = req
303 .schedule
304 .as_deref()
305 .ok_or_else(|| anyhow!("missing required field `schedule`"))?;
306 let command = req
307 .command
308 .as_deref()
309 .ok_or_else(|| anyhow!("missing required field `command`"))?;
310
311 let cron_expr = resolve_schedule(raw_schedule);
312 let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
313 let task = store.add(id, &cron_expr, command)?;
314
315 let mut out = format!(
316 "created task: id={}, schedule={}, command={}, enabled={}",
317 task.id, task.schedule, task.command, task.enabled
318 );
319 if cron_expr != raw_schedule {
320 out.push_str(&format!(
321 "\n(interpreted \"{}\" as cron: {})",
322 raw_schedule, cron_expr
323 ));
324 }
325 Ok(ToolResult { output: out })
326}
327
328fn handle_list(ctx: &ToolContext) -> anyhow::Result<ToolResult> {
329 let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
330 let tasks = store.list()?;
331 if tasks.is_empty() {
332 return Ok(ToolResult {
333 output: "no scheduled tasks".to_string(),
334 });
335 }
336 let lines: Vec<String> = tasks
337 .iter()
338 .map(|t| {
339 format!(
340 "id={} schedule={} command={} enabled={}",
341 t.id, t.schedule, t.command, t.enabled
342 )
343 })
344 .collect();
345 Ok(ToolResult {
346 output: lines.join("\n"),
347 })
348}
349
350fn handle_update(ctx: &ToolContext, req: &ScheduleInput) -> anyhow::Result<ToolResult> {
351 let id = require_id(req)?;
352 let schedule = req.schedule.as_deref().map(resolve_schedule);
353 let command = req.command.as_deref();
354
355 if schedule.is_none() && command.is_none() {
356 return Err(anyhow!(
357 "at least one of `schedule` or `command` must be provided"
358 ));
359 }
360
361 let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
362 let task = store.update(id, schedule.as_deref(), command)?;
363 Ok(ToolResult {
364 output: format!(
365 "updated task: id={}, schedule={}, command={}",
366 task.id, task.schedule, task.command
367 ),
368 })
369}
370
371fn handle_remove(ctx: &ToolContext, req: &ScheduleInput) -> anyhow::Result<ToolResult> {
372 let id = require_id(req)?;
373 let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
374 store.remove(id)?;
375 Ok(ToolResult {
376 output: format!("removed task: {id}"),
377 })
378}
379
380fn handle_pause(ctx: &ToolContext, req: &ScheduleInput) -> anyhow::Result<ToolResult> {
381 let id = require_id(req)?;
382 let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
383 let task = store.pause(id)?;
384 Ok(ToolResult {
385 output: format!("paused task: id={}, enabled={}", task.id, task.enabled),
386 })
387}
388
389fn handle_resume(ctx: &ToolContext, req: &ScheduleInput) -> anyhow::Result<ToolResult> {
390 let id = require_id(req)?;
391 let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
392 let task = store.resume(id)?;
393 Ok(ToolResult {
394 output: format!("resumed task: id={}, enabled={}", task.id, task.enabled),
395 })
396}
397
398fn handle_parse(req: &ScheduleInput) -> anyhow::Result<ToolResult> {
399 let raw = req
400 .schedule
401 .as_deref()
402 .ok_or_else(|| anyhow!("missing required field `schedule`"))?;
403 match parse_natural_schedule(raw) {
404 Some(cron) => Ok(ToolResult {
405 output: format!("\"{}\" → {}", raw, cron),
406 }),
407 None => {
408 if looks_like_cron(&raw.to_ascii_lowercase()) {
409 Ok(ToolResult {
410 output: format!("\"{}\" is already a valid cron expression", raw),
411 })
412 } else {
413 Err(anyhow!(
414 "could not parse \"{}\" as a schedule expression",
415 raw
416 ))
417 }
418 }
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use std::fs;
426 use std::sync::atomic::{AtomicU64, Ordering};
427 use std::time::{SystemTime, UNIX_EPOCH};
428
429 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
430
431 fn temp_dir() -> PathBuf {
432 let nanos = SystemTime::now()
433 .duration_since(UNIX_EPOCH)
434 .expect("clock")
435 .as_nanos();
436 let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
437 let dir = std::env::temp_dir().join(format!(
438 "agentzero-schedule-{}-{nanos}-{seq}",
439 std::process::id()
440 ));
441 fs::create_dir_all(&dir).expect("temp dir should be created");
442 dir
443 }
444
445 #[test]
448 fn parse_every_n_minutes() {
449 assert_eq!(
450 parse_natural_schedule("every 5 minutes"),
451 Some("*/5 * * * *".to_string())
452 );
453 assert_eq!(
454 parse_natural_schedule("every 15 min"),
455 Some("*/15 * * * *".to_string())
456 );
457 }
458
459 #[test]
460 fn parse_every_n_hours() {
461 assert_eq!(
462 parse_natural_schedule("every 2 hours"),
463 Some("0 */2 * * *".to_string())
464 );
465 assert_eq!(
466 parse_natural_schedule("every 4 hrs"),
467 Some("0 */4 * * *".to_string())
468 );
469 }
470
471 #[test]
472 fn parse_every_minute() {
473 assert_eq!(
474 parse_natural_schedule("every minute"),
475 Some("* * * * *".to_string())
476 );
477 }
478
479 #[test]
480 fn parse_hourly() {
481 assert_eq!(
482 parse_natural_schedule("hourly"),
483 Some("0 * * * *".to_string())
484 );
485 assert_eq!(
486 parse_natural_schedule("every hour"),
487 Some("0 * * * *".to_string())
488 );
489 }
490
491 #[test]
492 fn parse_daily() {
493 assert_eq!(
494 parse_natural_schedule("daily"),
495 Some("0 0 * * *".to_string())
496 );
497 assert_eq!(
498 parse_natural_schedule("every day"),
499 Some("0 0 * * *".to_string())
500 );
501 }
502
503 #[test]
504 fn parse_daily_at_time() {
505 assert_eq!(
506 parse_natural_schedule("daily at 9am"),
507 Some("0 9 * * *".to_string())
508 );
509 assert_eq!(
510 parse_natural_schedule("daily at 2:30pm"),
511 Some("30 14 * * *".to_string())
512 );
513 assert_eq!(
514 parse_natural_schedule("daily at 14:30"),
515 Some("30 14 * * *".to_string())
516 );
517 assert_eq!(
518 parse_natural_schedule("every day at 12pm"),
519 Some("0 12 * * *".to_string())
520 );
521 assert_eq!(
522 parse_natural_schedule("daily at 12am"),
523 Some("0 0 * * *".to_string())
524 );
525 }
526
527 #[test]
528 fn parse_weekly() {
529 assert_eq!(
530 parse_natural_schedule("weekly"),
531 Some("0 0 * * 0".to_string())
532 );
533 assert_eq!(
534 parse_natural_schedule("weekly on monday"),
535 Some("0 0 * * 1".to_string())
536 );
537 assert_eq!(
538 parse_natural_schedule("every friday"),
539 Some("0 0 * * 5".to_string())
540 );
541 }
542
543 #[test]
544 fn parse_monthly() {
545 assert_eq!(
546 parse_natural_schedule("monthly"),
547 Some("0 0 1 * *".to_string())
548 );
549 }
550
551 #[test]
552 fn parse_returns_none_for_existing_cron() {
553 assert_eq!(parse_natural_schedule("*/5 * * * *"), None);
554 assert_eq!(parse_natural_schedule("0 9 * * 1"), None);
555 }
556
557 #[test]
558 fn parse_returns_none_for_unrecognized() {
559 assert_eq!(parse_natural_schedule("next tuesday"), None);
560 assert_eq!(parse_natural_schedule("gibberish"), None);
561 }
562
563 #[tokio::test]
566 async fn schedule_create_list_remove_roundtrip() {
567 let dir = temp_dir();
568 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
569 let tool = ScheduleTool;
570
571 let result = tool
573 .execute(
574 r#"{"action": "create", "id": "backup", "schedule": "every 5 minutes", "command": "echo hello"}"#,
575 &ctx,
576 )
577 .await
578 .expect("create should succeed");
579 assert!(result.output.contains("*/5 * * * *"));
580 assert!(result.output.contains("interpreted"));
581
582 let result = tool
584 .execute(r#"{"action": "list"}"#, &ctx)
585 .await
586 .expect("list should succeed");
587 assert!(result.output.contains("backup"));
588
589 let result = tool
591 .execute(r#"{"action": "remove", "id": "backup"}"#, &ctx)
592 .await
593 .expect("remove should succeed");
594 assert!(result.output.contains("removed"));
595
596 let result = tool
598 .execute(r#"{"action": "list"}"#, &ctx)
599 .await
600 .expect("list should succeed");
601 assert!(result.output.contains("no scheduled tasks"));
602
603 fs::remove_dir_all(dir).ok();
604 }
605
606 #[tokio::test]
607 async fn schedule_create_with_cron_expression() {
608 let dir = temp_dir();
609 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
610 let tool = ScheduleTool;
611
612 let result = tool
613 .execute(
614 r#"{"action": "create", "id": "job", "schedule": "0 9 * * 1", "command": "echo"}"#,
615 &ctx,
616 )
617 .await
618 .expect("create with cron should succeed");
619 assert!(result.output.contains("0 9 * * 1"));
620 assert!(!result.output.contains("interpreted"));
621
622 fs::remove_dir_all(dir).ok();
623 }
624
625 #[tokio::test]
626 async fn schedule_pause_resume() {
627 let dir = temp_dir();
628 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
629 let tool = ScheduleTool;
630
631 tool.execute(
632 r#"{"action": "create", "id": "job", "schedule": "hourly", "command": "test"}"#,
633 &ctx,
634 )
635 .await
636 .expect("create should succeed");
637
638 let result = tool
639 .execute(r#"{"action": "pause", "id": "job"}"#, &ctx)
640 .await
641 .expect("pause should succeed");
642 assert!(result.output.contains("enabled=false"));
643
644 let result = tool
645 .execute(r#"{"action": "resume", "id": "job"}"#, &ctx)
646 .await
647 .expect("resume should succeed");
648 assert!(result.output.contains("enabled=true"));
649
650 fs::remove_dir_all(dir).ok();
651 }
652
653 #[tokio::test]
654 async fn schedule_parse_action() {
655 let tool = ScheduleTool;
656 let ctx = ToolContext::new("/tmp".to_string());
657
658 let result = tool
659 .execute(
660 r#"{"action": "parse", "schedule": "every 5 minutes"}"#,
661 &ctx,
662 )
663 .await
664 .expect("parse should succeed");
665 assert!(result.output.contains("*/5 * * * *"));
666
667 let result = tool
668 .execute(r#"{"action": "parse", "schedule": "*/5 * * * *"}"#, &ctx)
669 .await
670 .expect("parse existing cron should succeed");
671 assert!(result.output.contains("already a valid cron"));
672 }
673
674 #[tokio::test]
675 async fn schedule_unknown_action_fails() {
676 let tool = ScheduleTool;
677 let ctx = ToolContext::new("/tmp".to_string());
678
679 let err = tool
680 .execute(r#"{"action": "destroy"}"#, &ctx)
681 .await
682 .expect_err("unknown action should fail");
683 assert!(err.to_string().contains("unknown action"));
684 }
685
686 #[tokio::test]
687 async fn schedule_missing_id_fails() {
688 let tool = ScheduleTool;
689 let ctx = ToolContext::new("/tmp".to_string());
690
691 let err = tool
692 .execute(r#"{"action": "remove"}"#, &ctx)
693 .await
694 .expect_err("missing id should fail");
695 assert!(err.to_string().contains("missing required field `id`"));
696 }
697}