use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum JobCategory {
SoftwareUpdate,
Troubleshoot,
Catalog,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RunStatus {
Queued,
Running,
Completed,
Failed,
Killed,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct UserInvokableJob {
pub id: String,
pub display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
pub category: JobCategory,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_run: Option<JobRun>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct JobRun {
pub run_id: String,
pub status: RunStatus,
pub started_at: chrono::DateTime<chrono::Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
pub struct JobsListParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<JobCategory>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct JobsListResult {
pub items: Vec<UserInvokableJob>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct JobsExecuteParams {
pub id: String,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct JobsExecuteResult {
pub run_id: String,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
pub struct JobsSubscribeParams {}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct JobsSubscribeResult {
pub subscription: String,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct JobsUnsubscribeParams {
pub subscription: String,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct JobProgress {
pub run_id: String,
pub status: RunStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stdout_chunk: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stderr_chunk: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct JobsKillParams {
pub run_id: String,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct JobsKillResult {
pub requested_at: chrono::DateTime<chrono::Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn job_category_serialises_snake_case() {
for (variant, expected) in [
(JobCategory::SoftwareUpdate, "\"software_update\""),
(JobCategory::Troubleshoot, "\"troubleshoot\""),
(JobCategory::Catalog, "\"catalog\""),
] {
let s = serde_json::to_string(&variant).unwrap();
assert_eq!(s, expected, "encode {variant:?}");
let back: JobCategory = serde_json::from_str(expected).unwrap();
assert_eq!(back, variant, "round-trip {expected}");
}
}
#[test]
fn run_status_serialises_snake_case() {
for (variant, expected) in [
(RunStatus::Queued, "\"queued\""),
(RunStatus::Running, "\"running\""),
(RunStatus::Completed, "\"completed\""),
(RunStatus::Failed, "\"failed\""),
(RunStatus::Killed, "\"killed\""),
] {
let s = serde_json::to_string(&variant).unwrap();
assert_eq!(s, expected, "encode {variant:?}");
let back: RunStatus = serde_json::from_str(expected).unwrap();
assert_eq!(back, variant, "round-trip {expected}");
}
}
#[test]
fn user_invokable_job_minimum_shape_decodes() {
let wire = r#"{
"id":"chrome-update","display_name":"Chrome を更新",
"category":"software_update","version":"1.2.0"
}"#;
let j: UserInvokableJob = serde_json::from_str(wire).unwrap();
assert_eq!(j.id, "chrome-update");
assert!(j.display_description.is_none());
assert!(j.icon.is_none());
assert!(j.last_run.is_none());
}
#[test]
fn job_progress_status_transition_omits_chunks() {
let p = JobProgress {
run_id: "run-1".into(),
status: RunStatus::Running,
stdout_chunk: None,
stderr_chunk: None,
exit_code: None,
};
let v = serde_json::to_value(&p).unwrap();
assert!(v.get("stdout_chunk").is_none(), "wire: {v:?}");
assert!(v.get("stderr_chunk").is_none(), "wire: {v:?}");
assert!(v.get("exit_code").is_none(), "wire: {v:?}");
}
#[test]
fn job_progress_terminal_push_carries_exit_code() {
let p = JobProgress {
run_id: "run-1".into(),
status: RunStatus::Completed,
stdout_chunk: None,
stderr_chunk: None,
exit_code: Some(0),
};
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["status"], "completed");
assert_eq!(v["exit_code"], 0);
}
#[test]
fn jobs_list_filter_optional() {
let p = JobsListParams::default();
let v = serde_json::to_value(&p).unwrap();
assert!(v.get("category").is_none(), "wire: {v:?}");
}
#[test]
fn jobs_execute_result_round_trips() {
let r = JobsExecuteResult {
run_id: "run-uuid-1".into(),
};
let json = serde_json::to_string(&r).unwrap();
let back: JobsExecuteResult = serde_json::from_str(&json).unwrap();
assert_eq!(back.run_id, "run-uuid-1");
}
#[test]
fn job_run_serialises_with_optional_finish() {
let r = JobRun {
run_id: "run-1".into(),
status: RunStatus::Running,
started_at: chrono::Utc.with_ymd_and_hms(2026, 5, 24, 0, 0, 0).unwrap(),
finished_at: None,
exit_code: None,
};
let v = serde_json::to_value(&r).unwrap();
assert!(v.get("finished_at").is_none(), "wire: {v:?}");
assert!(v.get("exit_code").is_none(), "wire: {v:?}");
}
}