1use serde::{Deserialize, Serialize};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[allow(clippy::exhaustive_structs)]
24pub struct JobId(String);
25
26impl JobId {
27 #[must_use]
29 pub fn new() -> Self {
30 Self(crate::utils::generate_id())
31 }
32
33 #[must_use]
35 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40impl Default for JobId {
41 fn default() -> Self {
42 Self::new()
43 }
44}
45
46#[allow(clippy::exhaustive_enums)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum JobState {
57 Pending,
59 Validating,
61 Validated,
63 Executing,
65 Completed,
67 Failed,
69 RolledBack,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
91#[allow(clippy::exhaustive_structs)]
92pub struct Job {
93 pub id: JobId,
95 pub capability: String,
97 pub args: serde_json::Value,
99 pub state: JobState,
101 pub created_at: u64,
103 pub updated_at: u64,
105 pub output: Option<serde_json::Value>,
107 pub error: Option<String>,
109 pub dry_run: bool,
111}
112
113impl Job {
114 #[must_use]
122 pub fn new(capability: String, args: serde_json::Value, dry_run: bool) -> Self {
123 let now = SystemTime::now()
124 .duration_since(UNIX_EPOCH)
125 .unwrap_or_default()
126 .as_secs();
127 Self {
128 id: JobId::new(),
129 capability,
130 args,
131 state: JobState::Pending,
132 created_at: now,
133 updated_at: now,
134 output: None,
135 error: None,
136 dry_run,
137 }
138 }
139
140 #[allow(clippy::match_like_matches_macro, clippy::unnested_or_patterns)]
157 pub fn transition_to(&mut self, new_state: JobState) -> Result<(), String> {
158 let valid = matches!(
159 (self.state, new_state),
160 (JobState::Pending, JobState::Validating)
161 | (JobState::Validating, JobState::Validated)
162 | (JobState::Validating, JobState::Failed)
163 | (JobState::Validated, JobState::Executing)
164 | (JobState::Executing, JobState::Completed)
165 | (JobState::Executing, JobState::Failed)
166 | (JobState::Completed, JobState::RolledBack)
167 );
168
169 if valid {
170 self.state = new_state;
171 self.updated_at = SystemTime::now()
172 .duration_since(UNIX_EPOCH)
173 .unwrap_or_default()
174 .as_secs();
175 Ok(())
176 } else {
177 Err(format!(
178 "Invalid state transition: {:?} -> {:?}",
179 self.state, new_state
180 ))
181 }
182 }
183}
184
185#[cfg(test)]
186#[allow(clippy::unwrap_used)]
187mod tests {
188 use super::*;
189 use serde_json::json;
190
191 #[test]
192 fn test_job_state_valid_transitions() {
193 let mut job = Job::new("FileRead".into(), json!({"path": "/tmp/x"}), false);
194 assert_eq!(job.state, JobState::Pending);
195
196 job.transition_to(JobState::Validating).unwrap();
198 assert_eq!(job.state, JobState::Validating);
199
200 job.transition_to(JobState::Validated).unwrap();
201 assert_eq!(job.state, JobState::Validated);
202
203 job.transition_to(JobState::Executing).unwrap();
204 assert_eq!(job.state, JobState::Executing);
205
206 job.transition_to(JobState::Completed).unwrap();
207 assert_eq!(job.state, JobState::Completed);
208
209 job.transition_to(JobState::RolledBack).unwrap();
211 assert_eq!(job.state, JobState::RolledBack);
212 }
213
214 #[test]
215 fn test_job_state_invalid_transitions() {
216 let mut job = Job::new("FileRead".into(), json!({"path": "/tmp/x"}), false);
217
218 let result = job.transition_to(JobState::Executing);
220 assert!(result.is_err(), "Pending → Executing should be invalid");
221 assert_eq!(job.state, JobState::Pending); let result = job.transition_to(JobState::Completed);
225 assert!(result.is_err(), "Pending → Completed should be invalid");
226
227 job.transition_to(JobState::Validating).unwrap();
229 job.transition_to(JobState::Validated).unwrap();
230 job.transition_to(JobState::Executing).unwrap();
231 job.transition_to(JobState::Completed).unwrap();
232
233 let result = job.transition_to(JobState::Executing);
235 assert!(result.is_err(), "Completed → Executing should be invalid");
236 assert_eq!(job.state, JobState::Completed);
237
238 let result = job.transition_to(JobState::Validated);
240 assert!(result.is_err(), "Completed → Validated should be invalid");
241 }
242
243 #[test]
244 fn test_job_id_uniqueness() {
245 let mut seen = std::collections::HashSet::new();
247 for _ in 0..100 {
248 let id = JobId::new();
249 let s = id.as_str().to_string();
250 assert!(!s.is_empty(), "JobId should not be empty");
251 assert_eq!(s.len(), 32, "JobId should be 32 hex chars for urandom mode");
252 assert!(
253 seen.insert(s),
254 "JobId collision detected after {} IDs",
255 seen.len()
256 );
257 }
258 assert_eq!(seen.len(), 100);
259 }
260
261 #[test]
262 fn test_job_id_format() {
263 let id = JobId::new();
264 let s = id.as_str();
265 assert!(
267 s.chars().all(|c| c.is_ascii_hexdigit()),
268 "JobId must be hex: {}",
269 s
270 );
271 }
272
273 #[test]
274 fn test_job_state_failed_paths() {
275 let mut job = Job::new("ShellExec".into(), json!({"cmd": "bad"}), false);
277 job.transition_to(JobState::Validating).unwrap();
278 job.transition_to(JobState::Failed).unwrap();
279 assert_eq!(job.state, JobState::Failed);
280
281 let mut job2 = Job::new("ShellExec".into(), json!({"cmd": "bad"}), false);
283 job2.transition_to(JobState::Validating).unwrap();
284 job2.transition_to(JobState::Validated).unwrap();
285 job2.transition_to(JobState::Executing).unwrap();
286 job2.transition_to(JobState::Failed).unwrap();
287 assert_eq!(job2.state, JobState::Failed);
288 }
289
290 #[test]
291 fn test_job_timestamps() {
292 let before = SystemTime::now()
293 .duration_since(UNIX_EPOCH)
294 .unwrap()
295 .as_secs();
296 let job = Job::new("FileRead".into(), json!({}), false);
297 let after = SystemTime::now()
298 .duration_since(UNIX_EPOCH)
299 .unwrap()
300 .as_secs();
301
302 assert!(job.created_at >= before);
303 assert!(job.created_at <= after);
304 assert_eq!(job.created_at, job.updated_at); }
306
307 #[test]
308 fn test_job_transition_updates_timestamp() {
309 let mut job = Job::new("FileRead".into(), json!({}), false);
310 let created = job.updated_at;
311
312 std::thread::sleep(std::time::Duration::from_millis(10));
314 job.transition_to(JobState::Validating).unwrap();
315 assert!(job.updated_at >= created);
316 }
317}