1use std::path::PathBuf;
4use std::time::SystemTime;
5
6use serde::{Deserialize, Serialize};
7
8use crate::clock;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18#[serde(transparent)]
19pub struct JobId(pub u64);
20
21impl std::fmt::Display for JobId {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 write!(f, "{}", self.0)
24 }
25}
26
27#[non_exhaustive]
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39#[serde(rename_all = "lowercase")]
40pub enum JobStatus {
41 Running,
43 Stopped,
45 Done,
47 Killed,
54 Failed,
56}
57
58impl std::fmt::Display for JobStatus {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 JobStatus::Running => write!(f, "Running"),
62 JobStatus::Stopped => write!(f, "Stopped"),
63 JobStatus::Done => write!(f, "Done"),
64 JobStatus::Killed => write!(f, "Killed"),
65 JobStatus::Failed => write!(f, "Failed"),
66 }
67 }
68}
69
70#[non_exhaustive]
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74pub struct JobInfo {
75 pub id: JobId,
77 pub command: String,
79 pub status: JobStatus,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub output_file: Option<PathBuf>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub pid: Option<u32>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub exit_code: Option<i64>,
96 #[serde(with = "crate::rfc3339::system_time")]
102 #[cfg_attr(feature = "schema", schemars(schema_with = "crate::rfc3339::schema"))]
103 pub started_at: SystemTime,
104 #[serde(
107 default,
108 skip_serializing_if = "Option::is_none",
109 with = "crate::rfc3339::opt_system_time"
110 )]
111 #[cfg_attr(feature = "schema", schemars(schema_with = "crate::rfc3339::opt_schema"))]
112 pub finished_at: Option<SystemTime>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub pgids: Vec<u32>,
120}
121
122impl JobInfo {
123 pub fn new(id: JobId, command: impl Into<String>, status: JobStatus) -> Self {
132 Self {
133 id,
134 command: command.into(),
135 status,
136 output_file: None,
137 pid: None,
138 exit_code: None,
139 started_at: clock::system_now(),
140 finished_at: None,
141 pgids: Vec::new(),
142 }
143 }
144
145 pub fn with_output_file(mut self, output_file: Option<PathBuf>) -> Self {
147 self.output_file = output_file;
148 self
149 }
150
151 pub fn with_pid(mut self, pid: Option<u32>) -> Self {
153 self.pid = pid;
154 self
155 }
156
157 pub fn with_exit_code(mut self, exit_code: Option<i64>) -> Self {
159 self.exit_code = exit_code;
160 self
161 }
162
163 pub fn with_started_at(mut self, started_at: SystemTime) -> Self {
165 self.started_at = started_at;
166 self
167 }
168
169 pub fn with_finished_at(mut self, finished_at: Option<SystemTime>) -> Self {
171 self.finished_at = finished_at;
172 self
173 }
174
175 pub fn with_pgids(mut self, pgids: Vec<u32>) -> Self {
177 self.pgids = pgids;
178 self
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn new_defaults_optional_fields_to_none() {
188 let before = std::time::SystemTime::now();
189 let info = JobInfo::new(JobId(1), "echo hi", JobStatus::Running);
190 assert_eq!(info.id, JobId(1));
191 assert_eq!(info.command, "echo hi");
192 assert_eq!(info.status, JobStatus::Running);
193 assert!(info.output_file.is_none());
194 assert!(info.pid.is_none());
195 assert!(info.exit_code.is_none());
196 assert!(info.finished_at.is_none());
197 assert!(info.pgids.is_empty());
198 assert!(
200 info.started_at >= before,
201 "started_at should default to roughly now"
202 );
203 assert!(
204 info.started_at.duration_since(before).unwrap_or_default() < std::time::Duration::from_secs(5),
205 "started_at default drifted too far from now"
206 );
207 }
208
209 #[test]
210 fn with_setters_chain_and_override_defaults() {
211 let started = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000);
212 let finished = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_010);
213 let info = JobInfo::new(JobId(2), "sleep 1", JobStatus::Done)
214 .with_output_file(Some(PathBuf::from("job-output.txt")))
215 .with_pid(Some(1234))
216 .with_exit_code(Some(0))
217 .with_started_at(started)
218 .with_finished_at(Some(finished))
219 .with_pgids(vec![4242, 4243]);
220 assert_eq!(info.output_file, Some(PathBuf::from("job-output.txt")));
221 assert_eq!(info.pid, Some(1234));
222 assert_eq!(info.exit_code, Some(0));
223 assert_eq!(info.started_at, started);
224 assert_eq!(info.finished_at, Some(finished));
225 assert_eq!(info.pgids, vec![4242, 4243]);
226 }
227
228 #[test]
231 fn job_id_serializes_transparent() {
232 assert_eq!(serde_json::to_value(JobId(42)).unwrap(), serde_json::json!(42));
233 let back: JobId = serde_json::from_value(serde_json::json!(42)).unwrap();
234 assert_eq!(back, JobId(42));
235 }
236
237 #[test]
240 fn job_status_json_spelling_is_lowercase() {
241 assert_eq!(serde_json::to_string(&JobStatus::Running).unwrap(), "\"running\"");
246 assert_eq!(serde_json::to_string(&JobStatus::Stopped).unwrap(), "\"stopped\"");
247 assert_eq!(serde_json::to_string(&JobStatus::Done).unwrap(), "\"done\"");
248 assert_eq!(serde_json::to_string(&JobStatus::Killed).unwrap(), "\"killed\"");
249 assert_eq!(serde_json::to_string(&JobStatus::Failed).unwrap(), "\"failed\"");
250 }
251
252 #[test]
253 fn job_status_round_trips_through_serde() {
254 for status in [
255 JobStatus::Running,
256 JobStatus::Stopped,
257 JobStatus::Done,
258 JobStatus::Killed,
259 JobStatus::Failed,
260 ] {
261 let json = serde_json::to_string(&status).unwrap();
262 let back: JobStatus = serde_json::from_str(&json).unwrap();
263 assert_eq!(back, status);
264 }
265 }
266
267 #[test]
270 fn job_info_omits_unset_optional_fields_from_the_wire() {
271 let info = JobInfo::new(JobId(4), "sleep 5", JobStatus::Running);
274 let json = serde_json::to_value(&info).unwrap();
275 let obj = json.as_object().unwrap();
276 assert!(!obj.contains_key("output_file"), "{json}");
277 assert!(!obj.contains_key("pid"), "{json}");
278 assert!(!obj.contains_key("exit_code"), "{json}");
279 assert!(!obj.contains_key("finished_at"), "{json}");
280 assert!(!obj.contains_key("pgids"), "{json}");
281 assert!(obj.contains_key("started_at"), "{json}");
283 assert!(obj.contains_key("status"), "{json}");
284 }
285
286 #[test]
289 fn job_info_timestamps_serialize_as_rfc3339_utc_strings() {
290 let started = std::time::UNIX_EPOCH + std::time::Duration::new(1_700_000_000, 123_456_789);
294 let finished = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_005);
295 let info = JobInfo::new(JobId(6), "sleep 5", JobStatus::Done)
296 .with_started_at(started)
297 .with_finished_at(Some(finished));
298 let json = serde_json::to_value(&info).unwrap();
299 assert_eq!(json["started_at"], "2023-11-14T22:13:20.123Z", "{json}");
300 assert_eq!(json["finished_at"], "2023-11-14T22:13:25.000Z", "{json}");
301 }
302
303 #[test]
304 fn job_info_timestamps_parse_second_through_nanosecond_precision() {
305 let base = serde_json::json!({
306 "id": 7, "command": "x", "status": "done",
307 "started_at": "2023-11-14T22:13:20Z",
308 });
309 let back: JobInfo = serde_json::from_value(base).unwrap();
310 assert_eq!(
311 back.started_at,
312 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000)
313 );
314
315 let nanos = serde_json::json!({
316 "id": 7, "command": "x", "status": "done",
317 "started_at": "2023-11-14T22:13:20.123456789Z",
318 });
319 let back: JobInfo = serde_json::from_value(nanos).unwrap();
320 assert_eq!(
321 back.started_at,
322 std::time::UNIX_EPOCH + std::time::Duration::new(1_700_000_000, 123_456_789)
323 );
324 }
325
326 #[test]
327 fn job_info_timestamps_reject_junk_loud() {
328 for bad in [
332 "2023-11-14 22:13:20Z", "2023-11-14T22:13:20", "2023-11-14T22:13:20+00:00", "1969-12-31T23:59:59Z", "2023-13-01T00:00:00Z", "2023-02-29T00:00:00Z", "2023-11-14T24:00:00Z", "not-a-time",
340 ] {
341 let v = serde_json::json!({
342 "id": 8, "command": "x", "status": "done", "started_at": bad,
343 });
344 let r: Result<JobInfo, _> = serde_json::from_value(v);
345 assert!(r.is_err(), "{bad:?} must be rejected");
346 let msg = r.unwrap_err().to_string();
347 assert!(
348 msg.contains("RFC 3339"),
349 "error for {bad:?} must name the expected format: {msg}"
350 );
351 }
352 }
353
354 #[test]
355 fn job_info_leap_day_round_trips() {
356 let leap = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_709_164_800);
357 let info = JobInfo::new(JobId(9), "x", JobStatus::Done).with_started_at(leap);
358 let json = serde_json::to_value(&info).unwrap();
359 assert_eq!(json["started_at"], "2024-02-29T00:00:00.000Z", "{json}");
360 let back: JobInfo = serde_json::from_value(json).unwrap();
361 assert_eq!(back.started_at, leap);
362 }
363
364 #[test]
365 fn job_info_exit_code_present_when_job_failed() {
366 let info = JobInfo::new(JobId(5), "sh -c 'exit 42'", JobStatus::Failed)
370 .with_exit_code(Some(42));
371 let json = serde_json::to_value(&info).unwrap();
372 assert_eq!(json["exit_code"], 42);
373 assert_eq!(json["status"], "failed");
374 }
375}