use sea_orm::FromQueryResult;
use sea_orm::prelude::DateTimeWithTimeZone;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
Pending,
Running,
Completed,
Failed,
}
impl std::fmt::Display for JobStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
JobStatus::Pending => "pending",
JobStatus::Running => "running",
JobStatus::Completed => "completed",
JobStatus::Failed => "failed",
};
f.write_str(s)
}
}
impl std::str::FromStr for JobStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"pending" => Ok(JobStatus::Pending),
"running" => Ok(JobStatus::Running),
"completed" => Ok(JobStatus::Completed),
"failed" => Ok(JobStatus::Failed),
other => Err(format!("unknown job status: {other}")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, FromQueryResult)]
pub struct JobRow {
pub id: Uuid,
pub queue: String,
pub job_type: String,
pub payload: serde_json::Value,
pub status: String,
pub attempts: i32,
pub max_retries: i32,
pub run_at: DateTimeWithTimeZone,
pub started_at: Option<DateTimeWithTimeZone>,
pub locked_until: Option<DateTimeWithTimeZone>,
pub finished_at: Option<DateTimeWithTimeZone>,
pub last_error: Option<String>,
pub trace_id: Option<String>,
pub created_at: DateTimeWithTimeZone,
}
impl JobRow {
pub fn parse_status(&self) -> Result<JobStatus, String> {
self.status.parse()
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL_STATUSES: &[JobStatus] = &[
JobStatus::Pending,
JobStatus::Running,
JobStatus::Completed,
JobStatus::Failed,
];
#[test]
fn display_produces_lowercase() {
let expected = [
(JobStatus::Pending, "pending"),
(JobStatus::Running, "running"),
(JobStatus::Completed, "completed"),
(JobStatus::Failed, "failed"),
];
for (status, label) in expected {
assert_eq!(status.to_string(), label);
}
}
#[test]
fn from_str_parses_all_variants() {
for &status in ALL_STATUSES {
let parsed: JobStatus = status.to_string().parse().unwrap();
assert_eq!(parsed, status);
}
}
#[test]
fn display_from_str_roundtrip() {
for &status in ALL_STATUSES {
let s = status.to_string();
let back: JobStatus = s.parse().unwrap();
assert_eq!(back, status, "roundtrip failed for {s}");
}
}
#[test]
fn from_str_rejects_unknown() {
let err = "nope".parse::<JobStatus>().unwrap_err();
assert!(err.contains("nope"), "error should include the bad value");
}
#[test]
fn from_str_rejects_uppercase() {
assert!("Pending".parse::<JobStatus>().is_err());
assert!("RUNNING".parse::<JobStatus>().is_err());
}
#[test]
fn serde_roundtrip_all_variants() {
for &status in ALL_STATUSES {
let json = serde_json::to_string(&status).unwrap();
let back: JobStatus = serde_json::from_str(&json).unwrap();
assert_eq!(back, status, "serde roundtrip failed for {json}");
}
}
#[test]
fn serde_uses_lowercase() {
assert_eq!(
serde_json::to_string(&JobStatus::Pending).unwrap(),
r#""pending""#
);
assert_eq!(
serde_json::to_string(&JobStatus::Completed).unwrap(),
r#""completed""#
);
}
fn make_row(status: &str) -> JobRow {
use sea_orm::prelude::DateTimeWithTimeZone;
let now: DateTimeWithTimeZone = "2026-01-01T00:00:00+00:00".parse().unwrap();
JobRow {
id: Uuid::nil(),
queue: "default".into(),
job_type: "send_email".into(),
payload: serde_json::json!({}),
status: status.into(),
attempts: 0,
max_retries: 3,
run_at: now,
started_at: None,
locked_until: None,
finished_at: None,
last_error: None,
trace_id: None,
created_at: now,
}
}
#[test]
fn parse_status_valid() {
let row = make_row("completed");
assert_eq!(row.parse_status().unwrap(), JobStatus::Completed);
}
#[test]
fn parse_status_invalid() {
let row = make_row("exploded");
let err = row.parse_status().unwrap_err();
assert!(err.contains("exploded"));
}
}