use crate::source::Span;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OverlapPolicy {
#[default]
Skip,
Queue,
Replace,
}
impl fmt::Display for OverlapPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Skip => write!(f, "skip"),
Self::Queue => write!(f, "queue"),
Self::Replace => write!(f, "replace"),
}
}
}
impl FromStr for OverlapPolicy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"skip" => Ok(Self::Skip),
"queue" => Ok(Self::Queue),
"replace" => Ok(Self::Replace),
other => Err(format!(
"invalid overlap policy '{other}' — must be skip, queue, or replace"
)),
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScheduleSource {
#[default]
Cli,
Yaml,
}
impl fmt::Display for ScheduleSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Cli => write!(f, "cli"),
Self::Yaml => write!(f, "yaml"),
}
}
}
impl FromStr for ScheduleSource {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"cli" => Ok(Self::Cli),
"yaml" => Ok(Self::Yaml),
other => Err(format!(
"invalid schedule source '{other}' — must be cli or yaml"
)),
}
}
}
#[derive(Debug, Clone)]
pub struct ScheduleConfig {
pub cron: String,
pub timezone: Option<String>,
pub human: Option<String>,
pub overlap: OverlapPolicy,
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: OverlapPolicy = map
.get("overlap")
.and_then(|v| v.as_str())
.unwrap_or("skip")
.parse()?;
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: OverlapPolicy::Skip,
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}"))?;
validate_cron(&cron)?;
let tz = schedule.timezone().map(|s| s.to_string());
return Ok(ScheduleConfig {
cron,
timezone: tz,
human: Some(trimmed.to_string()),
overlap: OverlapPolicy::Skip,
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: OverlapPolicy::Skip,
paused: false,
span,
});
}
validate_cron(trimmed)?;
Ok(ScheduleConfig {
cron: trimmed.to_string(),
timezone: None,
human: None,
overlap: OverlapPolicy::Skip,
paused: false,
span,
})
}
fn validate_cron(expr: &str) -> Result<(), String> {
if !expr.starts_with('@') {
let field_count = expr.split_whitespace().count();
if field_count != 5 {
return Err(format!(
"expected 5-field cron expression, got {field_count} fields. \
Nika uses standard cron (minute hour day month weekday)."
));
}
}
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
)
})
}
pub 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, OverlapPolicy::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("5-field"), "got: {err}");
let val = serde_json::json!("99 99 99 99 99");
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 reject_six_field_cron() {
let val = serde_json::json!("0 0 9 * * *");
let err = parse_schedule_value(&val, test_span()).unwrap_err();
assert!(
err.contains("5-field"),
"should mention 5-field requirement: {err}"
);
}
#[test]
fn accept_five_field_cron() {
let val = serde_json::json!("0 9 * * *");
let config = parse_schedule_value(&val, test_span()).unwrap();
assert_eq!(config.cron, "0 9 * * *");
}
#[test]
fn accept_preset_no_field_count_check() {
let val = serde_json::json!("@daily");
let config = parse_schedule_value(&val, test_span()).unwrap();
assert_eq!(config.cron, "@daily");
}
#[test]
fn overlap_policy_roundtrip() {
assert_eq!("skip".parse::<OverlapPolicy>().unwrap(), OverlapPolicy::Skip);
assert_eq!("queue".parse::<OverlapPolicy>().unwrap(), OverlapPolicy::Queue);
assert_eq!("replace".parse::<OverlapPolicy>().unwrap(), OverlapPolicy::Replace);
assert!("invalid".parse::<OverlapPolicy>().is_err());
assert_eq!(OverlapPolicy::Skip.to_string(), "skip");
assert_eq!(OverlapPolicy::default(), OverlapPolicy::Skip);
}
#[test]
fn schedule_source_roundtrip() {
assert_eq!("cli".parse::<ScheduleSource>().unwrap(), ScheduleSource::Cli);
assert_eq!("yaml".parse::<ScheduleSource>().unwrap(), ScheduleSource::Yaml);
assert!("api".parse::<ScheduleSource>().is_err());
assert_eq!(ScheduleSource::Cli.to_string(), "cli");
assert_eq!(ScheduleSource::default(), ScheduleSource::Cli);
}
#[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}");
}
}