pub mod cron;
pub mod host;
pub mod manager;
pub mod platform;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const SCHEDULED_TASK_SCHEMA_VERSION: i64 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScheduledTask {
pub id: String,
pub automation_task_id: String,
pub cron_expression: String,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScheduledTaskInput {
pub id: String,
pub automation_task_id: String,
pub cron_expression: String,
pub enabled: bool,
}
pub fn validate_schedule_input(input: &ScheduledTaskInput) -> Result<ScheduledTaskInput, String> {
let id = validate_schedule_id(&input.id)?;
let automation_task_id = trim_non_empty(&input.automation_task_id, "Automation task ID")?;
let cron_expression = input.cron_expression.trim().to_string();
if cron_expression.is_empty() {
return Err("Cron expression must not be empty".to_string());
}
cron::CronExpression::parse(&cron_expression)?;
Ok(ScheduledTaskInput {
id,
automation_task_id,
cron_expression,
enabled: input.enabled,
})
}
fn trim_non_empty(raw: &str, label: &str) -> Result<String, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(format!("{} must not be empty", label));
}
if trimmed.as_bytes().iter().any(|b| b.is_ascii_control()) {
return Err(format!("{} must not contain control characters", label));
}
Ok(trimmed.to_string())
}
fn validate_schedule_id(id: &str) -> Result<String, String> {
let trimmed = trim_non_empty(id, "Schedule ID")?;
if trimmed.contains('/') || trimmed.contains('\\') {
return Err("Schedule ID must not contain path separators".to_string());
}
if trimmed == "." || trimmed == ".." || trimmed.contains("..") {
return Err("Schedule ID must not contain path traversal sequences".to_string());
}
Ok(trimmed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScheduleHealth {
Healthy,
Degraded,
Unavailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DesiredState {
Present,
Missing,
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReferenceState {
Valid,
Missing,
Invalid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionState {
Ready,
UnsafePolicy,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HostState {
Installed,
Disabled,
Missing,
Drifted,
Corrupt,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostRunMetadata {
pub last_run: Option<DateTime<Utc>>,
pub next_run: Option<DateTime<Utc>>,
pub last_result: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScheduleDiagnostic {
pub kind: ScheduleDiagnosticKind,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScheduleDiagnosticKind {
InstallationFailed,
NotInstalled,
ScheduleDrift,
CorruptHostEntry,
OrphanedHostEntry,
MissingTask,
InvalidTask,
UnsafePolicy,
UnsupportedSchedule,
PlatformUnavailable,
RunnerPathDrift,
DesiredDeletionFailed,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScheduleStatus {
pub schedule_id: String,
pub health: ScheduleHealth,
pub desired_state: DesiredState,
pub reference_state: ReferenceState,
pub execution_state: ExecutionState,
pub host_state: HostState,
pub diagnostics: Vec<ScheduleDiagnostic>,
#[serde(skip_serializing_if = "Option::is_none")]
pub host_metadata: Option<HostRunMetadata>,
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_input() -> ScheduledTaskInput {
ScheduledTaskInput {
id: "morning-brief".to_string(),
automation_task_id: "daily-report".to_string(),
cron_expression: "0 9 * * *".to_string(),
enabled: true,
}
}
#[test]
fn valid_input_passes_validation() {
let result = validate_schedule_input(&valid_input()).unwrap();
assert_eq!(result.id, "morning-brief");
assert_eq!(result.automation_task_id, "daily-report");
assert_eq!(result.cron_expression, "0 9 * * *");
assert!(result.enabled);
}
#[test]
fn empty_id_rejected() {
let mut input = valid_input();
input.id = " ".to_string();
assert!(validate_schedule_input(&input).is_err());
}
#[test]
fn empty_task_id_rejected() {
let mut input = valid_input();
input.automation_task_id = "".to_string();
assert!(validate_schedule_input(&input).is_err());
}
#[test]
fn empty_cron_rejected() {
let mut input = valid_input();
input.cron_expression = " ".to_string();
assert!(validate_schedule_input(&input).is_err());
}
#[test]
fn invalid_cron_rejected() {
let mut input = valid_input();
input.cron_expression = "not-cron".to_string();
assert!(validate_schedule_input(&input).is_err());
}
#[test]
fn macro_cron_rejected() {
let mut input = valid_input();
input.cron_expression = "@daily".to_string();
assert!(validate_schedule_input(&input).is_err());
}
#[test]
fn stepped_cron_accepted() {
let mut input = valid_input();
input.cron_expression = "*/15 9-17/2 * 1,6,12 1-5".to_string();
assert!(validate_schedule_input(&input).is_ok());
}
#[test]
fn scheduled_task_serializes() {
let now = Utc::now();
let task = ScheduledTask {
id: "s1".to_string(),
automation_task_id: "t1".to_string(),
cron_expression: "0 9 * * *".to_string(),
enabled: true,
created_at: now,
updated_at: now,
};
let json = serde_json::to_string(&task).unwrap();
let restored: ScheduledTask = serde_json::from_str(&json).unwrap();
assert_eq!(task, restored);
}
}