use crate::source::Span;
#[derive(Debug, Clone)]
pub struct ScheduleConfig {
pub cron: String,
pub timezone: Option<String>,
pub human: Option<String>,
pub overlap: String,
pub paused: bool,
pub span: Span,
}
pub fn parse_schedule_value(
value: &serde_json::Value,
span: Span,
) -> Result<ScheduleConfig, String> {
match value {
serde_json::Value::String(s) => parse_schedule_string(s, span),
serde_json::Value::Object(map) => {
let cron_str = map
.get("cron")
.or_else(|| map.get("every"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
"schedule object requires a 'cron' field (e.g. schedule: { cron: \"@daily\" })"
.to_string()
})?;
let config = parse_schedule_string(cron_str, span)?;
let timezone = map
.get("timezone")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(ref tz) = timezone {
validate_timezone(tz)?;
}
let overlap = map
.get("overlap")
.and_then(|v| v.as_str())
.unwrap_or("skip")
.to_string();
if !["skip", "queue", "replace"].contains(&overlap.as_str()) {
return Err(format!(
"invalid overlap policy '{}' — must be skip, queue, or replace",
overlap
));
}
let paused = map.get("paused").and_then(|v| v.as_bool()).unwrap_or(false);
Ok(ScheduleConfig {
timezone: timezone.or(config.timezone),
overlap,
paused,
..config
})
}
_ => Err("schedule must be a string or object".to_string()),
}
}
fn parse_schedule_string(s: &str, span: Span) -> Result<ScheduleConfig, String> {
let trimmed = s.trim();
if trimmed.starts_with('@') {
validate_cron(trimmed)?;
return Ok(ScheduleConfig {
cron: trimmed.to_string(),
timezone: None,
human: Some(trimmed.to_string()),
overlap: "skip".to_string(),
paused: false,
span,
});
}
if trimmed.starts_with("every ") {
if let Ok(schedule) = hron::Schedule::parse(trimmed) {
let cron = schedule
.to_cron()
.map_err(|e| format!("hron→cron conversion failed: {e}"))?;
let tz = schedule.timezone().map(|s| s.to_string());
return Ok(ScheduleConfig {
cron,
timezone: tz,
human: Some(trimmed.to_string()),
overlap: "skip".to_string(),
paused: false,
span,
});
}
}
if let Some(cron) = duration_to_cron(trimmed) {
validate_cron(&cron)?;
return Ok(ScheduleConfig {
cron,
timezone: None,
human: Some(format!("every {trimmed}")),
overlap: "skip".to_string(),
paused: false,
span,
});
}
validate_cron(trimmed)?;
Ok(ScheduleConfig {
cron: trimmed.to_string(),
timezone: None,
human: None,
overlap: "skip".to_string(),
paused: false,
span,
})
}
fn validate_cron(expr: &str) -> Result<(), String> {
expr.parse::<croner::Cron>()
.map(|_| ())
.map_err(|e| format!("invalid cron expression '{}': {}", expr, e))
}
fn validate_timezone(tz: &str) -> Result<(), String> {
tz.parse::<chrono_tz::Tz>().map(|_| ()).map_err(|_| {
format!(
"invalid timezone '{}' — use IANA name (e.g. Europe/Paris)",
tz
)
})
}
fn duration_to_cron(s: &str) -> Option<String> {
let s = s.trim();
if let Some(rest) = s.strip_suffix('h') {
let n: u32 = rest.parse().ok()?;
if n == 0 || n > 23 {
return None;
}
Some(format!("0 */{n} * * *"))
} else if let Some(rest) = s.strip_suffix('m') {
let n: u32 = rest.parse().ok()?;
if n == 0 || n > 59 {
return None;
}
Some(format!("*/{n} * * * *"))
} else if let Some(rest) = s.strip_suffix('d') {
let n: u32 = rest.parse().ok()?;
if n == 0 || n > 28 {
return None;
}
if n == 1 {
Some("0 0 * * *".to_string())
} else {
Some(format!("0 0 */{n} * *"))
}
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::source::Span;
fn test_span() -> Span {
Span::dummy()
}
#[test]
fn parse_raw_cron() {
let val = serde_json::json!("0 9 * * *");
let config = parse_schedule_value(&val, test_span()).unwrap();
assert_eq!(config.cron, "0 9 * * *");
assert!(config.human.is_none());
assert!(config.timezone.is_none());
}
#[test]
fn parse_preset() {
let val = serde_json::json!("@daily");
let config = parse_schedule_value(&val, test_span()).unwrap();
assert_eq!(config.cron, "@daily");
assert_eq!(config.human.as_deref(), Some("@daily"));
}
#[test]
fn parse_object_form() {
let val = serde_json::json!({
"cron": "0 9 * * 1-5",
"timezone": "Europe/Paris",
"overlap": "queue"
});
let config = parse_schedule_value(&val, test_span()).unwrap();
assert_eq!(config.cron, "0 9 * * 1-5");
assert_eq!(config.timezone.as_deref(), Some("Europe/Paris"));
assert_eq!(config.overlap, "queue");
}
#[test]
fn parse_invalid_cron() {
let val = serde_json::json!("not a cron");
let err = parse_schedule_value(&val, test_span()).unwrap_err();
assert!(err.contains("invalid cron"), "got: {err}");
}
#[test]
fn parse_invalid_timezone() {
let val = serde_json::json!({
"cron": "@daily",
"timezone": "Mars/Olympus"
});
let err = parse_schedule_value(&val, test_span()).unwrap_err();
assert!(err.contains("invalid timezone"), "got: {err}");
}
#[test]
fn parse_duration_shorthand() {
let val = serde_json::json!("6h");
let config = parse_schedule_value(&val, test_span()).unwrap();
assert_eq!(config.cron, "0 */6 * * *");
assert_eq!(config.human.as_deref(), Some("every 6h"));
let val = serde_json::json!("30m");
let config = parse_schedule_value(&val, test_span()).unwrap();
assert_eq!(config.cron, "*/30 * * * *");
}
#[test]
fn parse_hron_string() {
let val = serde_json::json!("every day at 9:00");
let result = parse_schedule_value(&val, test_span());
if let Ok(config) = result {
assert!(
config.cron.contains('9'),
"cron should reference hour 9: {}",
config.cron
);
assert!(config.human.is_some());
}
}
#[test]
fn parse_invalid_overlap() {
let val = serde_json::json!({
"cron": "@daily",
"overlap": "invalid"
});
let err = parse_schedule_value(&val, test_span()).unwrap_err();
assert!(err.contains("overlap"), "got: {err}");
}
}