use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct JobId(pub String);
impl JobId {
pub fn new() -> Self {
Self(format!("job_{}", uuid::Uuid::now_v7()))
}
pub fn from_string(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl Default for JobId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for JobId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for JobId {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
#[non_exhaustive]
pub enum JobStatus {
Running {
started_at_unix: u64,
},
Completed {
exit_code: Option<i32>,
stdout: String,
stderr: String,
duration_secs: f64,
},
Failed {
error: String,
duration_secs: f64,
},
TimedOut {
stdout: String,
stderr: String,
duration_secs: f64,
},
Cancelled {
duration_secs: f64,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BackgroundJob {
pub id: JobId,
pub command: String,
pub working_dir: Option<String>,
pub timeout_secs: u64,
pub started_at_unix: u64,
pub status: JobStatus,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct JobSummary {
pub id: JobId,
pub command: String,
pub status: String,
pub started_at_unix: u64,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_job_id_format() {
let id = JobId::new();
assert!(id.0.starts_with("job_"), "JobId should start with 'job_'");
assert_eq!(id.0.len(), 40, "JobId should be 40 characters");
let uuid_part = &id.0[4..];
assert!(uuid::Uuid::parse_str(uuid_part).is_ok());
}
#[test]
fn test_job_id_new_unique() {
let id1 = JobId::new();
let id2 = JobId::new();
assert_ne!(id1, id2, "Generated JobIds should be unique");
}
#[test]
fn test_job_id_serde_roundtrip() {
let id = JobId::from_string("job_01hx7z8k9m2n3p4q5r6s7t8u9v");
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, "\"job_01hx7z8k9m2n3p4q5r6s7t8u9v\"");
let parsed: JobId = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, id);
}
#[test]
fn test_job_id_display() {
let id = JobId::from_string("job_01hx7z8k9m2n3p4q5r6s7t8u9v");
assert_eq!(format!("{id}"), "job_01hx7z8k9m2n3p4q5r6s7t8u9v");
}
#[test]
fn test_job_id_as_ref() {
let id = JobId::from_string("job_01hx7z8k9m2n3p4q5r6s7t8u9v");
let s: &str = id.as_ref();
assert_eq!(s, "job_01hx7z8k9m2n3p4q5r6s7t8u9v");
}
#[test]
fn test_job_id_default() {
let id = JobId::default();
assert!(id.0.starts_with("job_"));
assert_eq!(id.0.len(), 40);
}
#[test]
fn test_job_status_variants() {
let running = JobStatus::Running {
started_at_unix: 1706123456,
};
let completed = JobStatus::Completed {
exit_code: Some(0),
stdout: "output".to_string(),
stderr: "".to_string(),
duration_secs: 1.5,
};
let failed = JobStatus::Failed {
error: "spawn error".to_string(),
duration_secs: 0.1,
};
let timed_out = JobStatus::TimedOut {
stdout: "partial".to_string(),
stderr: "".to_string(),
duration_secs: 30.0,
};
let cancelled = JobStatus::Cancelled { duration_secs: 5.0 };
assert_ne!(running, completed);
assert_ne!(completed, failed);
assert_ne!(failed, timed_out);
assert_ne!(timed_out, cancelled);
}
#[test]
fn test_job_status_serde_roundtrip() {
let running = JobStatus::Running {
started_at_unix: 1706123456,
};
let json = serde_json::to_string(&running).unwrap();
assert!(json.contains("\"status\":\"running\""));
assert!(json.contains("\"started_at_unix\":1706123456"));
let parsed: JobStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, running);
let completed = JobStatus::Completed {
exit_code: Some(0),
stdout: "hello".to_string(),
stderr: "warning".to_string(),
duration_secs: 2.5,
};
let json = serde_json::to_string(&completed).unwrap();
assert!(json.contains("\"status\":\"completed\""));
let parsed: JobStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, completed);
let failed = JobStatus::Failed {
error: "command not found".to_string(),
duration_secs: 0.01,
};
let json = serde_json::to_string(&failed).unwrap();
assert!(json.contains("\"status\":\"failed\""));
let parsed: JobStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, failed);
let timed_out = JobStatus::TimedOut {
stdout: "partial output".to_string(),
stderr: "".to_string(),
duration_secs: 30.0,
};
let json = serde_json::to_string(&timed_out).unwrap();
assert!(json.contains("\"status\":\"timed_out\""));
let parsed: JobStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, timed_out);
let cancelled = JobStatus::Cancelled { duration_secs: 5.5 };
let json = serde_json::to_string(&cancelled).unwrap();
assert!(json.contains("\"status\":\"cancelled\""));
let parsed: JobStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, cancelled);
}
#[test]
fn test_job_status_completed_fields() {
let completed = JobStatus::Completed {
exit_code: Some(1),
stdout: "some output".to_string(),
stderr: "some error".to_string(),
duration_secs: 10.25,
};
let json = serde_json::to_string(&completed).unwrap();
let json_val: serde_json::Value = serde_json::from_str(&json).unwrap();
let obj = json_val
.as_object()
.expect("JobStatus should serialize to JSON object");
assert_eq!(
obj.get("status").and_then(|v| v.as_str()),
Some("completed")
);
assert!(obj.contains_key("exit_code"));
assert!(obj.contains_key("stdout"));
assert!(obj.contains_key("stderr"));
assert!(obj.contains_key("duration_secs"));
let completed_no_exit = JobStatus::Completed {
exit_code: None,
stdout: "".to_string(),
stderr: "".to_string(),
duration_secs: 0.0,
};
let json = serde_json::to_string(&completed_no_exit).unwrap();
let json_val: serde_json::Value = serde_json::from_str(&json).unwrap();
let obj = json_val
.as_object()
.expect("JobStatus should serialize to JSON object");
assert_eq!(
obj.get("status").and_then(|v| v.as_str()),
Some("completed")
);
assert!(matches!(obj.get("exit_code"), Some(v) if v.is_null()));
assert!(obj.contains_key("stdout"));
assert!(obj.contains_key("stderr"));
assert!(obj.contains_key("duration_secs"));
}
#[test]
fn test_background_job_struct() {
let job = BackgroundJob {
id: JobId::from_string("job_01hx7z8k9m2n3p4q5r6s7t8u9v"),
command: "cargo build".to_string(),
working_dir: Some("/project".to_string()),
timeout_secs: 300,
started_at_unix: 1706123456,
status: JobStatus::Running {
started_at_unix: 1706123456,
},
};
assert_eq!(job.id.0, "job_01hx7z8k9m2n3p4q5r6s7t8u9v");
assert_eq!(job.command, "cargo build");
assert_eq!(job.working_dir, Some("/project".to_string()));
assert_eq!(job.timeout_secs, 300);
assert_eq!(job.started_at_unix, 1706123456);
assert!(matches!(job.status, JobStatus::Running { .. }));
}
#[test]
fn test_background_job_serde_roundtrip() {
let job = BackgroundJob {
id: JobId::from_string("job_01hx7z8k9m2n3p4q5r6s7t8u9v"),
command: "cargo test".to_string(),
working_dir: None,
timeout_secs: 60,
started_at_unix: 1706123400,
status: JobStatus::Completed {
exit_code: Some(0),
stdout: "All tests passed".to_string(),
stderr: "".to_string(),
duration_secs: 15.3,
},
};
let json = serde_json::to_string_pretty(&job).unwrap();
let parsed: BackgroundJob = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, job.id);
assert_eq!(parsed.command, job.command);
assert_eq!(parsed.working_dir, job.working_dir);
assert_eq!(parsed.timeout_secs, job.timeout_secs);
assert_eq!(parsed.started_at_unix, job.started_at_unix);
if let JobStatus::Completed {
exit_code,
stdout,
stderr,
duration_secs,
} = &parsed.status
{
assert_eq!(*exit_code, Some(0));
assert_eq!(stdout, "All tests passed");
assert_eq!(stderr, "");
assert!((*duration_secs - 15.3).abs() < f64::EPSILON);
} else {
unreachable!("Expected Completed status");
}
}
#[test]
fn test_job_summary_struct() {
let summary = JobSummary {
id: JobId::from_string("job_01hx7z8k9m2n3p4q5r6s7t8u9v"),
command: "npm test".to_string(),
status: "running".to_string(),
started_at_unix: 1706123500,
};
assert_eq!(summary.id.0, "job_01hx7z8k9m2n3p4q5r6s7t8u9v");
assert_eq!(summary.command, "npm test");
assert_eq!(summary.status, "running");
assert_eq!(summary.started_at_unix, 1706123500);
}
#[test]
fn test_job_summary_serde_roundtrip() {
let summary = JobSummary {
id: JobId::from_string("job_01hx7z9abcdefghijklmnopqr"),
command: "make build".to_string(),
status: "completed".to_string(),
started_at_unix: 1706123456,
};
let json = serde_json::to_string(&summary).unwrap();
let parsed: JobSummary = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, summary.id);
assert_eq!(parsed.command, summary.command);
assert_eq!(parsed.status, summary.status);
assert_eq!(parsed.started_at_unix, summary.started_at_unix);
}
#[test]
fn test_job_summary_status_values() {
let statuses = ["running", "completed", "failed", "timed_out", "cancelled"];
for status in statuses {
let summary = JobSummary {
id: JobId::from_string("job_test"),
command: "test".to_string(),
status: status.to_string(),
started_at_unix: 0,
};
assert_eq!(summary.status, status);
}
}
}