use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct JobId(Uuid);
impl JobId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub fn as_uuid(&self) -> &Uuid {
&self.0
}
}
impl Default for JobId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for JobId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<Uuid> for JobId {
fn from(uuid: Uuid) -> Self {
Self(uuid)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum JobStatus {
Scheduled,
Running,
Completed,
Failed(String),
Cancelled,
}
impl fmt::Display for JobStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Scheduled => write!(f, "Scheduled"),
Self::Running => write!(f, "Running"),
Self::Completed => write!(f, "Completed"),
Self::Failed(msg) => write!(f, "Failed: {}", msg),
Self::Cancelled => write!(f, "Cancelled"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobSpec {
pub label: String,
pub schedule: String,
pub metadata: HashMap<String, String>,
}
impl JobSpec {
pub fn new(label: impl Into<String>, schedule: impl Into<String>) -> Self {
Self {
label: label.into(),
schedule: schedule.into(),
metadata: HashMap::new(),
}
}
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobInfo {
pub id: JobId,
pub spec: JobSpec,
pub status: JobStatus,
pub created_at: DateTime<Utc>,
pub last_run: Option<DateTime<Utc>>,
pub next_run: Option<DateTime<Utc>>,
pub run_count: u32,
pub failure_count: u32,
}
#[derive(Debug, thiserror::Error)]
pub enum SchedulerError {
#[error("Invalid cron expression '{expression}': {reason}")]
InvalidCronExpression {
expression: String,
reason: String,
},
#[error("Job not found: {0}")]
JobNotFound(JobId),
#[error("Scheduler is not running")]
NotRunning,
#[error("Scheduler is already running")]
AlreadyRunning,
#[error("Job execution failed: {0}")]
ExecutionFailed(String),
#[error("Scheduler internal error: {0}")]
Internal(String),
}
#[async_trait]
pub trait SchedulerPort: Send + Sync {
async fn start(&self) -> Result<(), SchedulerError>;
async fn shutdown(&self) -> Result<(), SchedulerError>;
async fn schedule_job(&self, spec: JobSpec) -> Result<JobId, SchedulerError>;
async fn cancel_job(&self, job_id: &JobId) -> Result<(), SchedulerError>;
async fn get_job_status(&self, job_id: &JobId) -> Result<JobStatus, SchedulerError>;
async fn get_job_info(&self, job_id: &JobId) -> Result<JobInfo, SchedulerError>;
async fn list_jobs(&self) -> Result<Vec<JobInfo>, SchedulerError>;
fn is_running(&self) -> bool;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_job_id_display() {
let id = JobId::new();
let display = format!("{}", id);
assert!(!display.is_empty());
assert_eq!(display.len(), 36);
}
#[test]
fn test_job_id_from_uuid() {
let uuid = Uuid::new_v4();
let id = JobId::from_uuid(uuid);
assert_eq!(id.as_uuid(), &uuid);
}
#[test]
fn test_job_id_default() {
let id1 = JobId::default();
let id2 = JobId::default();
assert_ne!(id1, id2, "Two defaults should differ");
}
#[test]
fn test_job_status_display() {
assert_eq!(format!("{}", JobStatus::Scheduled), "Scheduled");
assert_eq!(format!("{}", JobStatus::Running), "Running");
assert_eq!(format!("{}", JobStatus::Completed), "Completed");
assert_eq!(
format!("{}", JobStatus::Failed("oops".into())),
"Failed: oops"
);
assert_eq!(format!("{}", JobStatus::Cancelled), "Cancelled");
}
#[test]
fn test_job_spec_builder() {
let spec = JobSpec::new("backup", "0 0 2 * * *")
.with_metadata("target", "database")
.with_metadata("retention", "30d");
assert_eq!(spec.label, "backup");
assert_eq!(spec.schedule, "0 0 2 * * *");
assert_eq!(spec.metadata.len(), 2);
assert_eq!(spec.metadata.get("target").unwrap(), "database");
}
#[test]
fn test_scheduler_error_display() {
let err = SchedulerError::InvalidCronExpression {
expression: "bad".into(),
reason: "parse failure".into(),
};
assert!(format!("{}", err).contains("bad"));
assert!(format!("{}", err).contains("parse failure"));
let err = SchedulerError::JobNotFound(JobId::new());
assert!(format!("{}", err).contains("Job not found"));
let err = SchedulerError::NotRunning;
assert!(format!("{}", err).contains("not running"));
}
}