use argentor_core::{ArgentorResult, ToolCall, ToolResult};
use argentor_skills::skill::{Skill, SkillDescriptor};
use async_trait::async_trait;
use chrono::{Datelike, NaiveDateTime, Timelike};
use serde_json::json;
pub struct CronParserSkill {
descriptor: SkillDescriptor,
}
impl CronParserSkill {
pub fn new() -> Self {
Self {
descriptor: SkillDescriptor {
name: "cron_parser".to_string(),
description: "Parse cron expressions, calculate next occurrences, validate, and generate human-readable descriptions.".to_string(),
parameters_schema: json!({
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["validate", "describe", "next", "next_n", "parse"],
"description": "The cron operation to perform"
},
"expression": {
"type": "string",
"description": "Cron expression (5 fields: minute hour day month weekday)"
},
"from": {
"type": "string",
"description": "Start datetime (ISO 8601) for next occurrence calculation"
},
"count": {
"type": "integer",
"description": "Number of next occurrences to calculate (max 20)"
}
},
"required": ["operation", "expression"]
}),
required_capabilities: vec![],
requires_approval: false,
},
}
}
}
impl Default for CronParserSkill {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
struct CronField {
values: Vec<u32>,
}
impl CronField {
fn matches(&self, value: u32) -> bool {
self.values.contains(&value)
}
}
fn parse_field(field: &str, min: u32, max: u32) -> Result<CronField, String> {
let mut values = Vec::new();
for part in field.split(',') {
let part = part.trim();
if part == "*" {
values.extend(min..=max);
} else if let Some(step_str) = part.strip_prefix("*/") {
let step: u32 = step_str
.parse()
.map_err(|_| format!("Invalid step value: {step_str}"))?;
if step == 0 {
return Err("Step value cannot be 0".to_string());
}
let mut v = min;
while v <= max {
values.push(v);
v += step;
}
} else if part.contains('-') {
let range_parts: Vec<&str> = part.split('-').collect();
if range_parts.len() != 2 {
return Err(format!("Invalid range: {part}"));
}
let start: u32 = range_parts[0]
.parse()
.map_err(|_| format!("Invalid range start: {}", range_parts[0]))?;
let end: u32 = range_parts[1]
.parse()
.map_err(|_| format!("Invalid range end: {}", range_parts[1]))?;
if start > end || start < min || end > max {
return Err(format!("Range {start}-{end} out of bounds ({min}-{max})"));
}
values.extend(start..=end);
} else {
let v: u32 = part.parse().map_err(|_| format!("Invalid value: {part}"))?;
if v < min || v > max {
return Err(format!("Value {v} out of bounds ({min}-{max})"));
}
values.push(v);
}
}
values.sort();
values.dedup();
Ok(CronField { values })
}
struct CronExpr {
minute: CronField,
hour: CronField,
day: CronField,
month: CronField,
weekday: CronField,
}
fn parse_cron(expr: &str) -> Result<CronExpr, String> {
let expr = match expr.trim() {
"@yearly" | "@annually" => "0 0 1 1 *",
"@monthly" => "0 0 1 * *",
"@weekly" => "0 0 * * 0",
"@daily" | "@midnight" => "0 0 * * *",
"@hourly" => "0 * * * *",
other => other,
};
let fields: Vec<&str> = expr.split_whitespace().collect();
if fields.len() != 5 {
return Err(format!(
"Expected 5 fields (minute hour day month weekday), got {}",
fields.len()
));
}
Ok(CronExpr {
minute: parse_field(fields[0], 0, 59)?,
hour: parse_field(fields[1], 0, 23)?,
day: parse_field(fields[2], 1, 31)?,
month: parse_field(fields[3], 1, 12)?,
weekday: parse_field(fields[4], 0, 6)?,
})
}
fn next_occurrences(expr: &CronExpr, from: NaiveDateTime, count: usize) -> Vec<NaiveDateTime> {
let mut results = Vec::new();
let mut current = from + chrono::Duration::minutes(1);
current = current
.date()
.and_hms_opt(current.hour(), current.minute(), 0)
.unwrap_or(current);
let max_iterations = 525_960; let mut iterations = 0;
while results.len() < count && iterations < max_iterations {
iterations += 1;
let minute = current.minute();
let hour = current.hour();
let day = current.day();
let month = current.month();
let weekday = current.weekday().num_days_from_sunday();
if expr.minute.matches(minute)
&& expr.hour.matches(hour)
&& expr.day.matches(day)
&& expr.month.matches(month)
&& expr.weekday.matches(weekday)
{
results.push(current);
}
current += chrono::Duration::minutes(1);
}
results
}
fn describe_cron(expr: &str) -> Result<String, String> {
match expr.trim() {
"@yearly" | "@annually" => return Ok("At midnight on January 1st, every year".to_string()),
"@monthly" => return Ok("At midnight on the 1st of every month".to_string()),
"@weekly" => return Ok("At midnight every Sunday".to_string()),
"@daily" | "@midnight" => return Ok("At midnight every day".to_string()),
"@hourly" => return Ok("At the start of every hour".to_string()),
_ => {}
}
let fields: Vec<&str> = expr.split_whitespace().collect();
if fields.len() != 5 {
return Err("Invalid cron expression".to_string());
}
let mut parts = Vec::new();
if fields[0] == "*" {
parts.push("Every minute".to_string());
} else if fields[0].starts_with("*/") {
parts.push(format!("Every {} minutes", &fields[0][2..]));
} else {
parts.push(format!("At minute {}", fields[0]));
}
if fields[1] != "*" {
if fields[1].starts_with("*/") {
parts.push(format!("every {} hours", &fields[1][2..]));
} else {
parts.push(format!("at hour {}", fields[1]));
}
}
if fields[2] != "*" {
parts.push(format!("on day {} of the month", fields[2]));
}
if fields[3] != "*" {
let month_name = match fields[3] {
"1" => "January",
"2" => "February",
"3" => "March",
"4" => "April",
"5" => "May",
"6" => "June",
"7" => "July",
"8" => "August",
"9" => "September",
"10" => "October",
"11" => "November",
"12" => "December",
other => other,
};
parts.push(format!("in {month_name}"));
}
if fields[4] != "*" {
let day_name = match fields[4] {
"0" => "Sunday",
"1" => "Monday",
"2" => "Tuesday",
"3" => "Wednesday",
"4" => "Thursday",
"5" => "Friday",
"6" => "Saturday",
other => other,
};
parts.push(format!("on {day_name}"));
}
Ok(parts.join(", "))
}
#[async_trait]
impl Skill for CronParserSkill {
fn descriptor(&self) -> &SkillDescriptor {
&self.descriptor
}
async fn execute(&self, call: ToolCall) -> ArgentorResult<ToolResult> {
let operation = match call.arguments["operation"].as_str() {
Some(op) => op,
None => {
return Ok(ToolResult::error(
&call.id,
"Missing required parameter: 'operation'",
))
}
};
let expression = match call.arguments["expression"].as_str() {
Some(v) => v,
None => {
return Ok(ToolResult::error(
&call.id,
"Missing required parameter: 'expression'",
))
}
};
match operation {
"validate" => {
match parse_cron(expression) {
Ok(_) => {
let response = json!({ "valid": true, "expression": expression });
Ok(ToolResult::success(&call.id, response.to_string()))
}
Err(e) => {
let response = json!({ "valid": false, "error": e });
Ok(ToolResult::success(&call.id, response.to_string()))
}
}
}
"describe" => {
match describe_cron(expression) {
Ok(desc) => {
let response = json!({ "description": desc, "expression": expression });
Ok(ToolResult::success(&call.id, response.to_string()))
}
Err(e) => Ok(ToolResult::error(&call.id, e)),
}
}
"parse" => {
match parse_cron(expression) {
Ok(cron) => {
let response = json!({
"expression": expression,
"minute": cron.minute.values,
"hour": cron.hour.values,
"day": cron.day.values,
"month": cron.month.values,
"weekday": cron.weekday.values
});
Ok(ToolResult::success(&call.id, response.to_string()))
}
Err(e) => Ok(ToolResult::error(&call.id, format!("Parse error: {e}"))),
}
}
"next" => {
let cron = match parse_cron(expression) {
Ok(c) => c,
Err(e) => return Ok(ToolResult::error(&call.id, format!("Parse error: {e}"))),
};
let from = if let Some(from_str) = call.arguments["from"].as_str() {
NaiveDateTime::parse_from_str(from_str, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(from_str, "%Y-%m-%d %H:%M:%S"))
.map_err(|e| format!("Invalid datetime: {e}"))
.unwrap_or_else(|_| chrono::Utc::now().naive_utc())
} else {
chrono::Utc::now().naive_utc()
};
let occurrences = next_occurrences(&cron, from, 1);
if let Some(next) = occurrences.first() {
let response = json!({
"next": next.format("%Y-%m-%dT%H:%M:%S").to_string(),
"expression": expression
});
Ok(ToolResult::success(&call.id, response.to_string()))
} else {
Ok(ToolResult::error(&call.id, "Could not find next occurrence within 1 year"))
}
}
"next_n" => {
let cron = match parse_cron(expression) {
Ok(c) => c,
Err(e) => return Ok(ToolResult::error(&call.id, format!("Parse error: {e}"))),
};
let count = call.arguments["count"]
.as_u64()
.unwrap_or(5)
.min(20) as usize;
let from = if let Some(from_str) = call.arguments["from"].as_str() {
NaiveDateTime::parse_from_str(from_str, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(from_str, "%Y-%m-%d %H:%M:%S"))
.unwrap_or_else(|_| chrono::Utc::now().naive_utc())
} else {
chrono::Utc::now().naive_utc()
};
let occurrences = next_occurrences(&cron, from, count);
let formatted: Vec<String> = occurrences
.iter()
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string())
.collect();
let response = json!({
"occurrences": formatted,
"count": formatted.len(),
"expression": expression
});
Ok(ToolResult::success(&call.id, response.to_string()))
}
_ => Ok(ToolResult::error(
&call.id,
format!("Unknown operation: '{operation}'. Supported: validate, describe, next, next_n, parse"),
)),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use serde_json::Value;
fn make_call(args: Value) -> ToolCall {
ToolCall {
id: "test".to_string(),
name: "cron_parser".to_string(),
arguments: args,
}
}
#[tokio::test]
async fn test_validate_valid() {
let skill = CronParserSkill::new();
let call = make_call(json!({"operation": "validate", "expression": "*/5 * * * *"}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["valid"], true);
}
#[tokio::test]
async fn test_validate_invalid() {
let skill = CronParserSkill::new();
let call = make_call(json!({"operation": "validate", "expression": "60 * * * *"}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["valid"], false);
}
#[tokio::test]
async fn test_validate_too_few_fields() {
let skill = CronParserSkill::new();
let call = make_call(json!({"operation": "validate", "expression": "* * *"}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["valid"], false);
}
#[tokio::test]
async fn test_describe_every_5_minutes() {
let skill = CronParserSkill::new();
let call = make_call(json!({"operation": "describe", "expression": "*/5 * * * *"}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
let desc = parsed["description"].as_str().unwrap();
assert!(desc.contains("5 minutes"));
}
#[tokio::test]
async fn test_describe_alias_daily() {
let skill = CronParserSkill::new();
let call = make_call(json!({"operation": "describe", "expression": "@daily"}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
let desc = parsed["description"].as_str().unwrap();
assert!(desc.contains("midnight"));
}
#[tokio::test]
async fn test_parse_fields() {
let skill = CronParserSkill::new();
let call = make_call(json!({"operation": "parse", "expression": "0 9 * * 1-5"}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["minute"], json!([0]));
assert_eq!(parsed["hour"], json!([9]));
assert_eq!(parsed["weekday"], json!([1, 2, 3, 4, 5]));
}
#[tokio::test]
async fn test_next_occurrence() {
let skill = CronParserSkill::new();
let call = make_call(json!({
"operation": "next",
"expression": "0 0 * * *",
"from": "2025-01-15T10:30:00"
}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["next"], "2025-01-16T00:00:00");
}
#[tokio::test]
async fn test_next_n_occurrences() {
let skill = CronParserSkill::new();
let call = make_call(json!({
"operation": "next_n",
"expression": "0 12 * * *",
"from": "2025-01-01T00:00:00",
"count": 3
}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["count"], 3);
let occs = parsed["occurrences"].as_array().unwrap();
assert_eq!(occs[0], "2025-01-01T12:00:00");
assert_eq!(occs[1], "2025-01-02T12:00:00");
assert_eq!(occs[2], "2025-01-03T12:00:00");
}
#[tokio::test]
async fn test_comma_separated_values() {
let cron = parse_cron("0,30 * * * *").unwrap();
assert_eq!(cron.minute.values, vec![0, 30]);
}
#[tokio::test]
async fn test_range_values() {
let cron = parse_cron("* 9-17 * * *").unwrap();
assert_eq!(cron.hour.values, vec![9, 10, 11, 12, 13, 14, 15, 16, 17]);
}
#[tokio::test]
async fn test_step_values() {
let cron = parse_cron("*/15 * * * *").unwrap();
assert_eq!(cron.minute.values, vec![0, 15, 30, 45]);
}
#[tokio::test]
async fn test_alias_hourly() {
let skill = CronParserSkill::new();
let call = make_call(json!({"operation": "parse", "expression": "@hourly"}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["minute"], json!([0]));
}
#[tokio::test]
async fn test_missing_operation() {
let skill = CronParserSkill::new();
let call = make_call(json!({"expression": "* * * * *"}));
let result = skill.execute(call).await.unwrap();
assert!(result.is_error);
}
#[tokio::test]
async fn test_unknown_operation() {
let skill = CronParserSkill::new();
let call = make_call(json!({"operation": "schedule", "expression": "* * * * *"}));
let result = skill.execute(call).await.unwrap();
assert!(result.is_error);
assert!(result.content.contains("Unknown operation"));
}
#[test]
fn test_descriptor_name() {
let skill = CronParserSkill::new();
assert_eq!(skill.descriptor().name, "cron_parser");
}
}