use super::TaskId;
use super::exceptions::TaskError;
use chrono::{DateTime, TimeDelta, Utc};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeriodicTask {
pub id: TaskId,
pub task_name: String,
pub schedule: Schedule,
pub enabled: bool,
pub last_run_at: Option<DateTime<Utc>>,
}
impl PeriodicTask {
pub fn new(
id: impl Into<TaskId>,
task_name: impl Into<String>,
schedule: Schedule,
) -> Result<Self, TaskError> {
let task = Self {
id: id.into(),
task_name: task_name.into(),
schedule,
enabled: true,
last_run_at: None,
};
task.validate()?;
Ok(task)
}
pub fn validate(&self) -> Result<(), TaskError> {
if self.id.trim().is_empty() {
return Err(TaskError::InvalidTaskId);
}
if self.task_name.trim().is_empty() {
return Err(TaskError::InvalidTaskName);
}
self.schedule.validate()
}
pub fn record_run(&mut self, ran_at: DateTime<Utc>) {
self.last_run_at = Some(ran_at);
}
#[must_use]
pub fn schedule_summary(&self) -> String {
self.schedule.summary()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Schedule {
Interval { every: TimeDelta },
Crontab(CrontabSchedule),
}
impl Schedule {
pub fn validate(&self) -> Result<(), TaskError> {
match self {
Self::Interval { every } if *every <= TimeDelta::zero() => Err(
TaskError::InvalidSchedule("interval must be greater than zero".to_string()),
),
Self::Interval { .. } => Ok(()),
Self::Crontab(schedule) => schedule.validate(),
}
}
#[must_use]
pub fn summary(&self) -> String {
match self {
Self::Interval { every } => format!("every {} seconds", every.num_seconds()),
Self::Crontab(schedule) => {
format!("crontab {} ({})", schedule.expression(), schedule.timezone)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrontabSchedule {
pub minute: String,
pub hour: String,
pub day_of_week: String,
pub day_of_month: String,
pub month_of_year: String,
pub timezone: String,
}
impl CrontabSchedule {
pub fn new(
minute: impl Into<String>,
hour: impl Into<String>,
day_of_week: impl Into<String>,
day_of_month: impl Into<String>,
month_of_year: impl Into<String>,
timezone: impl Into<String>,
) -> Result<Self, TaskError> {
let schedule = Self {
minute: minute.into(),
hour: hour.into(),
day_of_week: day_of_week.into(),
day_of_month: day_of_month.into(),
month_of_year: month_of_year.into(),
timezone: timezone.into(),
};
schedule.validate()?;
Ok(schedule)
}
pub fn validate(&self) -> Result<(), TaskError> {
validate_crontab_field("minute", &self.minute, false)?;
validate_crontab_field("hour", &self.hour, false)?;
validate_crontab_field("day_of_week", &self.day_of_week, true)?;
validate_crontab_field("day_of_month", &self.day_of_month, false)?;
validate_crontab_field("month_of_year", &self.month_of_year, false)?;
if self.timezone.trim().is_empty() {
return Err(TaskError::InvalidSchedule(
"timezone must not be blank".to_string(),
));
}
Ok(())
}
#[must_use]
pub fn expression(&self) -> String {
format!(
"{} {} {} {} {}",
self.minute, self.hour, self.day_of_week, self.day_of_month, self.month_of_year
)
}
}
fn validate_crontab_field(
field_name: &str,
value: &str,
allow_alpha: bool,
) -> Result<(), TaskError> {
if value.trim().is_empty() {
return Err(TaskError::InvalidSchedule(format!(
"{field_name} field must not be empty"
)));
}
if value.chars().any(char::is_whitespace) {
return Err(TaskError::InvalidSchedule(format!(
"{field_name} field must not contain spaces"
)));
}
let is_allowed = |character: char| {
character.is_ascii_digit()
|| matches!(character, '*' | ',' | '-' | '/')
|| (allow_alpha && character.is_ascii_alphabetic())
};
if !value.chars().all(is_allowed) {
return Err(TaskError::InvalidSchedule(format!(
"{field_name} field contains unsupported characters"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{CrontabSchedule, PeriodicTask, Schedule};
use crate::tasks::exceptions::TaskError;
use chrono::{TimeDelta, TimeZone, Utc};
#[test]
fn interval_periodic_task_uses_existing_task_id_shape() {
let task = PeriodicTask::new(
"cleanup:daily",
"cleanup",
Schedule::Interval {
every: TimeDelta::minutes(30),
},
)
.expect("interval schedule should be valid");
assert_eq!(task.id, "cleanup:daily");
assert_eq!(task.schedule_summary(), "every 1800 seconds");
assert!(task.enabled);
}
#[test]
fn invalid_interval_is_rejected() {
let error = PeriodicTask::new(
"task-1",
"cleanup",
Schedule::Interval {
every: TimeDelta::zero(),
},
)
.expect_err("zero interval should be invalid");
assert_eq!(
error,
TaskError::InvalidSchedule("interval must be greater than zero".to_string())
);
}
#[test]
fn crontab_schedule_accepts_named_weekdays_without_parsing_runtime_state() {
let schedule = CrontabSchedule::new("0", "7", "mon,fri", "*", "*", "UTC")
.expect("weekday names should be accepted");
assert_eq!(schedule.expression(), "0 7 mon,fri * *");
assert_eq!(
Schedule::Crontab(schedule).summary(),
"crontab 0 7 mon,fri * * (UTC)"
);
}
#[test]
fn crontab_schedule_rejects_unsupported_characters() {
let error = CrontabSchedule::new("?", "7", "*", "*", "*", "UTC")
.expect_err("question mark should be rejected by lightweight validation");
assert_eq!(
error,
TaskError::InvalidSchedule("minute field contains unsupported characters".to_string())
);
}
#[test]
fn periodic_task_records_last_run_without_claiming_scheduler_execution() {
let mut task = PeriodicTask::new(
"task-2",
"cleanup",
Schedule::Interval {
every: TimeDelta::minutes(5),
},
)
.expect("task should be valid");
let ran_at = Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
task.record_run(ran_at);
assert_eq!(task.last_run_at, Some(ran_at));
}
#[test]
fn blank_task_name_is_rejected() {
let error = PeriodicTask::new(
"task-3",
" ",
Schedule::Interval {
every: TimeDelta::minutes(1),
},
)
.expect_err("blank task name should be invalid");
assert_eq!(error, TaskError::InvalidTaskName);
}
}