use crate::error::{Error, Result};
use cron::Schedule;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Job {
pub schedule: String,
pub command: String,
#[serde(default)]
pub description: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub working_dir: Option<PathBuf>,
#[serde(default)]
pub environment: HashMap<String, String>,
#[serde(default)]
pub timeout: u64,
#[serde(default)]
pub shell: Option<String>,
#[serde(default)]
pub retry_count: u32,
#[serde(default = "default_retry_delay")]
pub retry_delay: u64,
#[serde(default)]
pub user: Option<String>,
#[serde(default = "default_capture_output")]
pub capture_output: bool,
#[serde(default = "default_max_output")]
pub max_output_size: usize,
#[serde(default)]
pub run_on_startup: bool,
#[serde(default)]
pub tags: Vec<String>,
}
fn default_enabled() -> bool {
true
}
fn default_retry_delay() -> u64 {
60
}
fn default_capture_output() -> bool {
true
}
fn default_max_output() -> usize {
1024 * 1024 }
impl Job {
pub fn validate(&self, name: &str) -> Result<()> {
self.parse_schedule().map_err(|e| Error::CronParse {
expr: self.schedule.clone(),
reason: e,
})?;
if self.command.trim().is_empty() {
return Err(Error::config(format!("Job '{}' has empty command", name)));
}
if let Some(ref dir) = self.working_dir {
if !dir.exists() {
tracing::warn!(
"Working directory '{}' for job '{}' does not exist",
dir.display(),
name
);
}
}
Ok(())
}
pub fn parse_schedule(&self) -> std::result::Result<Schedule, String> {
let expr = if self.schedule.split_whitespace().count() == 5 {
format!("0 {}", self.schedule)
} else {
self.schedule.clone()
};
Schedule::from_str(&expr).map_err(|e| e.to_string())
}
pub fn next_run(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.parse_schedule()
.ok()
.and_then(|schedule| schedule.upcoming(chrono::Utc).next())
}
pub fn retry_policy(&self) -> RetryPolicy {
RetryPolicy {
max_attempts: self.retry_count,
delay_seconds: self.retry_delay,
}
}
pub fn has_timeout(&self) -> bool {
self.timeout > 0
}
}
impl Default for Job {
fn default() -> Self {
Self {
schedule: "* * * * *".to_string(),
command: String::new(),
description: String::new(),
enabled: true,
working_dir: None,
environment: HashMap::new(),
timeout: 0,
shell: None,
retry_count: 0,
retry_delay: 60,
user: None,
capture_output: true,
max_output_size: 1024 * 1024,
run_on_startup: false,
tags: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub delay_seconds: u64,
}
impl RetryPolicy {
pub fn is_enabled(&self) -> bool {
self.max_attempts > 0
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum JobStatus {
Pending,
Running,
Success,
Failed { error: String },
Timeout,
Cancelled,
Retrying { attempt: u32 },
}
impl std::fmt::Display for JobStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pending => write!(f, "pending"),
Self::Running => write!(f, "running"),
Self::Success => write!(f, "success"),
Self::Failed { error } => write!(f, "failed: {}", error),
Self::Timeout => write!(f, "timeout"),
Self::Cancelled => write!(f, "cancelled"),
Self::Retrying { attempt } => write!(f, "retrying (attempt {})", attempt),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobExecution {
pub id: Uuid,
pub job_name: String,
pub started_at: chrono::DateTime<chrono::Utc>,
pub ended_at: Option<chrono::DateTime<chrono::Utc>>,
pub status: JobStatus,
pub exit_code: Option<i32>,
pub stdout: Option<String>,
pub stderr: Option<String>,
pub attempt: u32,
}
impl JobExecution {
pub fn new(job_name: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
job_name: job_name.into(),
started_at: chrono::Utc::now(),
ended_at: None,
status: JobStatus::Running,
exit_code: None,
stdout: None,
stderr: None,
attempt: 1,
}
}
pub fn complete_success(&mut self, exit_code: i32, stdout: String, stderr: String) {
self.ended_at = Some(chrono::Utc::now());
self.status = JobStatus::Success;
self.exit_code = Some(exit_code);
self.stdout = Some(stdout);
self.stderr = Some(stderr);
}
pub fn complete_failed(
&mut self,
error: String,
exit_code: Option<i32>,
stdout: String,
stderr: String,
) {
self.ended_at = Some(chrono::Utc::now());
self.status = JobStatus::Failed { error };
self.exit_code = exit_code;
self.stdout = Some(stdout);
self.stderr = Some(stderr);
}
pub fn complete_timeout(&mut self) {
self.ended_at = Some(chrono::Utc::now());
self.status = JobStatus::Timeout;
}
pub fn duration(&self) -> Option<chrono::Duration> {
self.ended_at.map(|end| end - self.started_at)
}
pub fn is_running(&self) -> bool {
matches!(self.status, JobStatus::Running)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_job_validation() {
let job = Job {
schedule: "*/5 * * * *".to_string(),
command: "echo hello".to_string(),
..Default::default()
};
assert!(job.validate("test").is_ok());
}
#[test]
fn test_invalid_schedule() {
let job = Job {
schedule: "invalid".to_string(),
command: "echo hello".to_string(),
..Default::default()
};
assert!(job.validate("test").is_err());
}
#[test]
fn test_empty_command() {
let job = Job {
schedule: "* * * * *".to_string(),
command: "".to_string(),
..Default::default()
};
assert!(job.validate("test").is_err());
}
#[test]
fn test_next_run() {
let job = Job {
schedule: "* * * * *".to_string(),
command: "echo test".to_string(),
..Default::default()
};
let next = job.next_run();
assert!(next.is_some());
}
#[test]
fn test_job_execution() {
let mut exec = JobExecution::new("test-job");
assert!(exec.is_running());
exec.complete_success(0, "output".to_string(), "".to_string());
assert!(!exec.is_running());
assert!(matches!(exec.status, JobStatus::Success));
assert!(exec.duration().is_some());
}
}