use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[allow(clippy::exhaustive_structs)]
pub struct JobId(String);
impl JobId {
#[must_use]
pub fn new() -> Self {
Self(crate::utils::generate_id())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for JobId {
fn default() -> Self {
Self::new()
}
}
#[allow(clippy::exhaustive_enums)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JobState {
Pending,
Validating,
Validated,
Executing,
Completed,
Failed,
RolledBack,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::exhaustive_structs)]
pub struct Job {
pub id: JobId,
pub capability: String,
pub args: serde_json::Value,
pub state: JobState,
pub created_at: u64,
pub updated_at: u64,
pub output: Option<serde_json::Value>,
pub error: Option<String>,
pub dry_run: bool,
}
impl Job {
#[must_use]
pub fn new(capability: String, args: serde_json::Value, dry_run: bool) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
id: JobId::new(),
capability,
args,
state: JobState::Pending,
created_at: now,
updated_at: now,
output: None,
error: None,
dry_run,
}
}
#[allow(clippy::match_like_matches_macro, clippy::unnested_or_patterns)]
pub fn transition_to(&mut self, new_state: JobState) -> Result<(), String> {
let valid = matches!(
(self.state, new_state),
(JobState::Pending, JobState::Validating)
| (JobState::Validating, JobState::Validated)
| (JobState::Validating, JobState::Failed)
| (JobState::Validated, JobState::Executing)
| (JobState::Executing, JobState::Completed)
| (JobState::Executing, JobState::Failed)
| (JobState::Completed, JobState::RolledBack)
);
if valid {
self.state = new_state;
self.updated_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Ok(())
} else {
Err(format!(
"Invalid state transition: {:?} -> {:?}",
self.state, new_state
))
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_job_state_valid_transitions() {
let mut job = Job::new("FileRead".into(), json!({"path": "/tmp/x"}), false);
assert_eq!(job.state, JobState::Pending);
job.transition_to(JobState::Validating).unwrap();
assert_eq!(job.state, JobState::Validating);
job.transition_to(JobState::Validated).unwrap();
assert_eq!(job.state, JobState::Validated);
job.transition_to(JobState::Executing).unwrap();
assert_eq!(job.state, JobState::Executing);
job.transition_to(JobState::Completed).unwrap();
assert_eq!(job.state, JobState::Completed);
job.transition_to(JobState::RolledBack).unwrap();
assert_eq!(job.state, JobState::RolledBack);
}
#[test]
fn test_job_state_invalid_transitions() {
let mut job = Job::new("FileRead".into(), json!({"path": "/tmp/x"}), false);
let result = job.transition_to(JobState::Executing);
assert!(result.is_err(), "Pending → Executing should be invalid");
assert_eq!(job.state, JobState::Pending);
let result = job.transition_to(JobState::Completed);
assert!(result.is_err(), "Pending → Completed should be invalid");
job.transition_to(JobState::Validating).unwrap();
job.transition_to(JobState::Validated).unwrap();
job.transition_to(JobState::Executing).unwrap();
job.transition_to(JobState::Completed).unwrap();
let result = job.transition_to(JobState::Executing);
assert!(result.is_err(), "Completed → Executing should be invalid");
assert_eq!(job.state, JobState::Completed);
let result = job.transition_to(JobState::Validated);
assert!(result.is_err(), "Completed → Validated should be invalid");
}
#[test]
fn test_job_id_uniqueness() {
let mut seen = std::collections::HashSet::new();
for _ in 0..100 {
let id = JobId::new();
let s = id.as_str().to_string();
assert!(!s.is_empty(), "JobId should not be empty");
assert_eq!(s.len(), 32, "JobId should be 32 hex chars for urandom mode");
assert!(
seen.insert(s),
"JobId collision detected after {} IDs",
seen.len()
);
}
assert_eq!(seen.len(), 100);
}
#[test]
fn test_job_id_format() {
let id = JobId::new();
let s = id.as_str();
assert!(
s.chars().all(|c| c.is_ascii_hexdigit()),
"JobId must be hex: {}",
s
);
}
#[test]
fn test_job_state_failed_paths() {
let mut job = Job::new("ShellExec".into(), json!({"cmd": "bad"}), false);
job.transition_to(JobState::Validating).unwrap();
job.transition_to(JobState::Failed).unwrap();
assert_eq!(job.state, JobState::Failed);
let mut job2 = Job::new("ShellExec".into(), json!({"cmd": "bad"}), false);
job2.transition_to(JobState::Validating).unwrap();
job2.transition_to(JobState::Validated).unwrap();
job2.transition_to(JobState::Executing).unwrap();
job2.transition_to(JobState::Failed).unwrap();
assert_eq!(job2.state, JobState::Failed);
}
#[test]
fn test_job_timestamps() {
let before = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let job = Job::new("FileRead".into(), json!({}), false);
let after = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(job.created_at >= before);
assert!(job.created_at <= after);
assert_eq!(job.created_at, job.updated_at); }
#[test]
fn test_job_transition_updates_timestamp() {
let mut job = Job::new("FileRead".into(), json!({}), false);
let created = job.updated_at;
std::thread::sleep(std::time::Duration::from_millis(10));
job.transition_to(JobState::Validating).unwrap();
assert!(job.updated_at >= created);
}
}