use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
use super::error::JobsError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobsConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_pid_file")]
pub pid_file: PathBuf,
#[serde(default = "default_state_db")]
pub state_db: PathBuf,
#[serde(default = "default_log_dir")]
pub log_dir: PathBuf,
#[serde(default = "default_metrics_port")]
pub metrics_port: u16,
#[serde(default = "default_max_concurrent")]
pub max_concurrent_jobs: usize,
#[serde(default = "default_timeout_secs")]
pub default_timeout_secs: u64,
#[serde(default)]
pub webhook: WebhookConfig,
#[serde(default)]
pub notify: NotifyConfig,
#[serde(default)]
pub definitions: Vec<JobDefinition>,
}
impl Default for JobsConfig {
fn default() -> Self {
Self {
enabled: default_enabled(),
pid_file: default_pid_file(),
state_db: default_state_db(),
log_dir: default_log_dir(),
metrics_port: default_metrics_port(),
max_concurrent_jobs: default_max_concurrent(),
default_timeout_secs: default_timeout_secs(),
webhook: WebhookConfig::default(),
notify: NotifyConfig::default(),
definitions: Vec::new(),
}
}
}
impl JobsConfig {
pub fn from_file(path: &Path) -> Result<Self, JobsError> {
if !path.exists() {
return Err(JobsError::ConfigNotFound {
path: path.to_path_buf(),
});
}
let content = std::fs::read_to_string(path).map_err(|e| JobsError::IoError {
reason: format!("Failed to read config file: {}", e),
})?;
toml::from_str(&content).map_err(|e| JobsError::ConfigParseError {
reason: format!("Failed to parse TOML: {}", e),
})
}
}
fn default_enabled() -> bool {
true
}
fn default_pid_file() -> PathBuf {
PathBuf::from(".nika/jobs/daemon.pid")
}
fn default_state_db() -> PathBuf {
PathBuf::from(".nika/jobs/state.db")
}
fn default_log_dir() -> PathBuf {
PathBuf::from(".nika/jobs/logs")
}
fn default_metrics_port() -> u16 {
9090
}
fn default_max_concurrent() -> usize {
10
}
fn default_timeout_secs() -> u64 {
300 }
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WebhookConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_webhook_port")]
pub port: u16,
#[serde(default = "default_webhook_host")]
pub host: String,
pub auth_token: Option<String>,
}
fn default_webhook_port() -> u16 {
8080
}
fn default_webhook_host() -> String {
"127.0.0.1".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NotifyConfig {
pub slack_webhook: Option<String>,
pub email: Option<EmailConfig>,
#[serde(default = "default_true")]
pub on_failure: bool,
#[serde(default)]
pub on_success: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailConfig {
pub smtp_host: String,
#[serde(default = "default_smtp_port")]
pub smtp_port: u16,
pub username: Option<String>,
pub password: Option<String>,
pub from: String,
pub to: Vec<String>,
}
fn default_smtp_port() -> u16 {
587
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobDefinition {
pub name: String,
pub workflow: PathBuf,
#[serde(default = "default_true")]
pub enabled: bool,
pub trigger: JobTrigger,
#[serde(default)]
pub retry: RetryConfig,
#[serde(with = "humantime_serde", default = "default_job_timeout")]
pub timeout: Duration,
#[serde(default)]
pub env: std::collections::HashMap<String, String>,
#[serde(default)]
pub tags: Vec<String>,
}
fn default_job_timeout() -> Duration {
Duration::from_secs(300)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum JobTrigger {
Cron(CronTriggerConfig),
Webhook(WebhookTriggerConfig),
Watch(WatchTriggerConfig),
Interval(IntervalTriggerConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronTriggerConfig {
pub expression: String,
#[serde(default = "default_timezone")]
pub timezone: String,
}
fn default_timezone() -> String {
"UTC".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookTriggerConfig {
pub path: String,
#[serde(default = "default_http_method")]
pub method: String,
pub secret: Option<String>,
}
fn default_http_method() -> String {
"POST".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchTriggerConfig {
pub paths: Vec<String>,
#[serde(with = "humantime_serde", default = "default_debounce")]
pub debounce: Duration,
#[serde(default = "default_watch_events")]
pub events: Vec<WatchEvent>,
}
fn default_debounce() -> Duration {
Duration::from_secs(5)
}
fn default_watch_events() -> Vec<WatchEvent> {
vec![WatchEvent::Create, WatchEvent::Modify]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WatchEvent {
Create,
Modify,
Delete,
Rename,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntervalTriggerConfig {
#[serde(with = "humantime_serde")]
pub every: Duration,
#[serde(with = "humantime_serde", default)]
pub initial_delay: Duration,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryConfig {
#[serde(default = "default_max_attempts")]
pub max_attempts: u32,
#[serde(default)]
pub backoff: BackoffStrategy,
#[serde(with = "humantime_serde", default = "default_initial_delay")]
pub initial_delay: Duration,
#[serde(with = "humantime_serde", default = "default_max_delay")]
pub max_delay: Duration,
#[serde(default = "default_true")]
pub jitter: bool,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: default_max_attempts(),
backoff: BackoffStrategy::default(),
initial_delay: default_initial_delay(),
max_delay: default_max_delay(),
jitter: true,
}
}
}
fn default_max_attempts() -> u32 {
3
}
fn default_initial_delay() -> Duration {
Duration::from_secs(1)
}
fn default_max_delay() -> Duration {
Duration::from_secs(60)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackoffStrategy {
Constant,
Linear,
#[default]
Exponential,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = JobsConfig::default();
assert!(config.enabled);
assert_eq!(config.max_concurrent_jobs, 10);
assert_eq!(config.metrics_port, 9090);
}
#[test]
fn test_cron_trigger_serde() {
let trigger = JobTrigger::Cron(CronTriggerConfig {
expression: "0 0 * * *".to_string(),
timezone: "UTC".to_string(),
});
let json = serde_json::to_string(&trigger).unwrap();
assert!(json.contains("cron"));
assert!(json.contains("0 0 * * *"));
}
#[test]
fn test_interval_trigger_serde() {
let trigger = JobTrigger::Interval(IntervalTriggerConfig {
every: Duration::from_secs(3600),
initial_delay: Duration::from_secs(0),
});
let json = serde_json::to_string(&trigger).unwrap();
assert!(json.contains("interval"));
}
#[test]
fn test_retry_config_default() {
let retry = RetryConfig::default();
assert_eq!(retry.max_attempts, 3);
assert_eq!(retry.backoff, BackoffStrategy::Exponential);
assert!(retry.jitter);
}
#[test]
fn test_job_definition() {
let job = JobDefinition {
name: "test-job".to_string(),
workflow: PathBuf::from("workflow.nika.yaml"),
enabled: true,
trigger: JobTrigger::Interval(IntervalTriggerConfig {
every: Duration::from_secs(60),
initial_delay: Duration::from_secs(0),
}),
retry: RetryConfig::default(),
timeout: Duration::from_secs(120),
env: std::collections::HashMap::new(),
tags: vec!["test".to_string()],
};
assert_eq!(job.name, "test-job");
assert!(job.enabled);
}
}