rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
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);
    }
}