use serde::{Deserialize, Serialize};
use crate::artifact::Artifact;
use crate::message::Message;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct TaskId(pub String);
impl TaskId {
#[must_use]
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn try_new(s: impl Into<String>) -> Result<Self, &'static str> {
let s = s.into();
if s.trim().is_empty() {
Err("TaskId must not be empty or whitespace-only")
} else {
Ok(Self(s))
}
}
}
impl std::fmt::Display for TaskId {
#[inline]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for TaskId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for TaskId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl AsRef<str> for TaskId {
#[inline]
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ContextId(pub String);
impl ContextId {
#[must_use]
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn try_new(s: impl Into<String>) -> Result<Self, &'static str> {
let s = s.into();
if s.trim().is_empty() {
Err("ContextId must not be empty or whitespace-only")
} else {
Ok(Self(s))
}
}
}
impl std::fmt::Display for ContextId {
#[inline]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for ContextId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for ContextId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl AsRef<str> for ContextId {
#[inline]
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct TaskVersion(pub u64);
impl TaskVersion {
#[must_use]
pub const fn new(v: u64) -> Self {
Self(v)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
impl std::fmt::Display for TaskVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<u64> for TaskVersion {
fn from(v: u64) -> Self {
Self(v)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TaskState {
#[serde(rename = "TASK_STATE_UNSPECIFIED", alias = "unspecified")]
Unspecified,
#[serde(rename = "TASK_STATE_SUBMITTED", alias = "submitted")]
Submitted,
#[serde(rename = "TASK_STATE_WORKING", alias = "working")]
Working,
#[serde(rename = "TASK_STATE_INPUT_REQUIRED", alias = "input-required")]
InputRequired,
#[serde(rename = "TASK_STATE_AUTH_REQUIRED", alias = "auth-required")]
AuthRequired,
#[serde(rename = "TASK_STATE_COMPLETED", alias = "completed")]
Completed,
#[serde(rename = "TASK_STATE_FAILED", alias = "failed")]
Failed,
#[serde(rename = "TASK_STATE_CANCELED", alias = "canceled")]
Canceled,
#[serde(rename = "TASK_STATE_REJECTED", alias = "rejected")]
Rejected,
}
impl TaskState {
#[inline]
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(
self,
Self::Completed | Self::Failed | Self::Canceled | Self::Rejected
)
}
#[inline]
#[must_use]
pub const fn is_interrupted(self) -> bool {
matches!(self, Self::InputRequired | Self::AuthRequired)
}
#[inline]
#[must_use]
pub const fn can_transition_to(self, next: Self) -> bool {
if self.is_terminal() {
return false;
}
if matches!(self, Self::Unspecified) {
return true;
}
matches!(
(self, next),
(Self::Submitted, Self::Working | Self::Failed | Self::Canceled | Self::Rejected)
| (Self::Working,
Self::Completed | Self::Failed | Self::Canceled | Self::InputRequired | Self::AuthRequired)
| (Self::InputRequired | Self::AuthRequired,
Self::Working | Self::Failed | Self::Canceled)
)
}
}
impl std::fmt::Display for TaskState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Unspecified => "TASK_STATE_UNSPECIFIED",
Self::Submitted => "TASK_STATE_SUBMITTED",
Self::Working => "TASK_STATE_WORKING",
Self::InputRequired => "TASK_STATE_INPUT_REQUIRED",
Self::AuthRequired => "TASK_STATE_AUTH_REQUIRED",
Self::Completed => "TASK_STATE_COMPLETED",
Self::Failed => "TASK_STATE_FAILED",
Self::Canceled => "TASK_STATE_CANCELED",
Self::Rejected => "TASK_STATE_REJECTED",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskStatus {
pub state: TaskState,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
impl TaskStatus {
#[must_use]
pub const fn new(state: TaskState) -> Self {
Self {
state,
message: None,
timestamp: None,
}
}
#[must_use]
pub fn with_timestamp(state: TaskState) -> Self {
Self {
state,
message: None,
timestamp: Some(crate::utc_now_iso8601()),
}
}
#[must_use]
pub fn has_valid_timestamp(&self) -> bool {
self.timestamp
.as_ref()
.is_none_or(|ts| ts.len() >= 19 && ts.contains('T'))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Task {
pub id: TaskId,
pub context_id: ContextId,
pub status: TaskStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub history: Option<Vec<Message>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub artifacts: Option<Vec<Artifact>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_task() -> Task {
Task {
id: TaskId::new("task-1"),
context_id: ContextId::new("ctx-1"),
status: TaskStatus::new(TaskState::Working),
history: None,
artifacts: None,
metadata: None,
}
}
#[test]
fn task_state_screaming_snake_serde() {
assert_eq!(
serde_json::to_string(&TaskState::InputRequired).expect("ser"),
"\"TASK_STATE_INPUT_REQUIRED\""
);
assert_eq!(
serde_json::to_string(&TaskState::AuthRequired).expect("ser"),
"\"TASK_STATE_AUTH_REQUIRED\""
);
assert_eq!(
serde_json::to_string(&TaskState::Submitted).expect("ser"),
"\"TASK_STATE_SUBMITTED\""
);
assert_eq!(
serde_json::to_string(&TaskState::Unspecified).expect("ser"),
"\"TASK_STATE_UNSPECIFIED\""
);
let back: TaskState = serde_json::from_str("\"completed\"").unwrap();
assert_eq!(back, TaskState::Completed);
let back: TaskState = serde_json::from_str("\"input-required\"").unwrap();
assert_eq!(back, TaskState::InputRequired);
}
#[test]
fn task_state_is_terminal() {
assert!(TaskState::Completed.is_terminal());
assert!(TaskState::Failed.is_terminal());
assert!(TaskState::Canceled.is_terminal());
assert!(TaskState::Rejected.is_terminal());
assert!(!TaskState::Working.is_terminal());
assert!(!TaskState::Submitted.is_terminal());
}
#[test]
fn task_roundtrip() {
let task = make_task();
let json = serde_json::to_string(&task).expect("serialize");
assert!(json.contains("\"id\":\"task-1\""));
let back: Task = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.id, TaskId::new("task-1"));
assert_eq!(back.context_id, ContextId::new("ctx-1"));
assert_eq!(back.status.state, TaskState::Working);
}
#[test]
fn optional_fields_omitted() {
let task = make_task();
let json = serde_json::to_string(&task).expect("serialize");
assert!(!json.contains("\"history\""), "history should be omitted");
assert!(
!json.contains("\"artifacts\""),
"artifacts should be omitted"
);
assert!(!json.contains("\"metadata\""), "metadata should be omitted");
}
#[test]
fn task_version_ordering() {
assert!(TaskVersion::new(2) > TaskVersion::new(1));
assert_eq!(TaskVersion::new(5).get(), 5);
}
#[test]
fn wire_format_submitted_state() {
let json = serde_json::to_string(&TaskState::Submitted).unwrap();
assert_eq!(json, "\"TASK_STATE_SUBMITTED\"");
let back: TaskState = serde_json::from_str("\"submitted\"").unwrap();
assert_eq!(back, TaskState::Submitted);
let back: TaskState = serde_json::from_str("\"TASK_STATE_SUBMITTED\"").unwrap();
assert_eq!(back, TaskState::Submitted);
}
#[test]
fn task_version_serde_roundtrip() {
let v = TaskVersion::new(42);
let json = serde_json::to_string(&v).expect("serialize");
assert_eq!(json, "42");
let back: TaskVersion = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, TaskVersion::new(42));
let v0 = TaskVersion::new(0);
let json0 = serde_json::to_string(&v0).expect("serialize zero");
assert_eq!(json0, "0");
let back0: TaskVersion = serde_json::from_str(&json0).expect("deserialize zero");
assert_eq!(back0, TaskVersion::new(0));
let vmax = TaskVersion::new(u64::MAX);
let json_max = serde_json::to_string(&vmax).expect("serialize max");
let back_max: TaskVersion = serde_json::from_str(&json_max).expect("deserialize max");
assert_eq!(back_max, vmax);
}
#[test]
fn empty_string_ids_work() {
let tid = TaskId::new("");
let json = serde_json::to_string(&tid).expect("serialize empty TaskId");
assert_eq!(json, "\"\"");
let back: TaskId = serde_json::from_str(&json).expect("deserialize empty TaskId");
assert_eq!(back, TaskId::new(""));
let cid = ContextId::new("");
let json = serde_json::to_string(&cid).expect("serialize empty ContextId");
assert_eq!(json, "\"\"");
let back: ContextId = serde_json::from_str(&json).expect("deserialize empty ContextId");
assert_eq!(back, ContextId::new(""));
let task = Task {
id: TaskId::new(""),
context_id: ContextId::new(""),
status: TaskStatus::new(TaskState::Submitted),
history: None,
artifacts: None,
metadata: None,
};
let json = serde_json::to_string(&task).expect("serialize task with empty ids");
let back: Task = serde_json::from_str(&json).expect("deserialize task with empty ids");
assert_eq!(back.id, TaskId::new(""));
assert_eq!(back.context_id, ContextId::new(""));
}
#[test]
fn task_state_display_trait() {
assert_eq!(TaskState::Working.to_string(), "TASK_STATE_WORKING");
assert_eq!(TaskState::Completed.to_string(), "TASK_STATE_COMPLETED");
assert_eq!(TaskState::Failed.to_string(), "TASK_STATE_FAILED");
assert_eq!(TaskState::Canceled.to_string(), "TASK_STATE_CANCELED");
assert_eq!(TaskState::Rejected.to_string(), "TASK_STATE_REJECTED");
assert_eq!(TaskState::Submitted.to_string(), "TASK_STATE_SUBMITTED");
assert_eq!(
TaskState::InputRequired.to_string(),
"TASK_STATE_INPUT_REQUIRED"
);
assert_eq!(
TaskState::AuthRequired.to_string(),
"TASK_STATE_AUTH_REQUIRED"
);
assert_eq!(TaskState::Unspecified.to_string(), "TASK_STATE_UNSPECIFIED");
}
#[test]
fn is_terminal_all_variants() {
assert!(!TaskState::Unspecified.is_terminal());
assert!(!TaskState::Submitted.is_terminal());
assert!(!TaskState::Working.is_terminal());
assert!(!TaskState::InputRequired.is_terminal());
assert!(!TaskState::AuthRequired.is_terminal());
assert!(TaskState::Completed.is_terminal());
assert!(TaskState::Failed.is_terminal());
assert!(TaskState::Canceled.is_terminal());
assert!(TaskState::Rejected.is_terminal());
}
#[test]
fn can_transition_to_valid_transitions() {
use TaskState::*;
for &target in &[
Unspecified,
Submitted,
Working,
InputRequired,
AuthRequired,
Completed,
Failed,
Canceled,
Rejected,
] {
assert!(
Unspecified.can_transition_to(target),
"Unspecified → {target:?} should be valid"
);
}
assert!(Submitted.can_transition_to(Working));
assert!(Submitted.can_transition_to(Failed));
assert!(Submitted.can_transition_to(Canceled));
assert!(Submitted.can_transition_to(Rejected));
assert!(Working.can_transition_to(Completed));
assert!(Working.can_transition_to(Failed));
assert!(Working.can_transition_to(Canceled));
assert!(Working.can_transition_to(InputRequired));
assert!(Working.can_transition_to(AuthRequired));
assert!(InputRequired.can_transition_to(Working));
assert!(InputRequired.can_transition_to(Failed));
assert!(InputRequired.can_transition_to(Canceled));
assert!(AuthRequired.can_transition_to(Working));
assert!(AuthRequired.can_transition_to(Failed));
assert!(AuthRequired.can_transition_to(Canceled));
}
#[test]
fn can_transition_to_invalid_transitions() {
use TaskState::*;
for &terminal in &[Completed, Failed, Canceled, Rejected] {
for &target in &[
Unspecified,
Submitted,
Working,
InputRequired,
AuthRequired,
Completed,
Failed,
Canceled,
Rejected,
] {
assert!(
!terminal.can_transition_to(target),
"{terminal:?} → {target:?} should be invalid (terminal state)"
);
}
}
assert!(!Submitted.can_transition_to(Completed));
assert!(!Submitted.can_transition_to(InputRequired));
assert!(!Submitted.can_transition_to(AuthRequired));
assert!(!Submitted.can_transition_to(Submitted));
assert!(!Submitted.can_transition_to(Unspecified));
assert!(!Working.can_transition_to(Submitted));
assert!(!Working.can_transition_to(Working));
assert!(!Working.can_transition_to(Unspecified));
assert!(!Working.can_transition_to(Rejected));
assert!(!InputRequired.can_transition_to(Completed));
assert!(!InputRequired.can_transition_to(Submitted));
assert!(!InputRequired.can_transition_to(InputRequired));
assert!(!InputRequired.can_transition_to(AuthRequired));
assert!(!InputRequired.can_transition_to(Unspecified));
assert!(!InputRequired.can_transition_to(Rejected));
assert!(!AuthRequired.can_transition_to(Completed));
assert!(!AuthRequired.can_transition_to(Submitted));
assert!(!AuthRequired.can_transition_to(InputRequired));
assert!(!AuthRequired.can_transition_to(AuthRequired));
assert!(!AuthRequired.can_transition_to(Unspecified));
assert!(!AuthRequired.can_transition_to(Rejected));
}
#[test]
fn task_id_display_and_as_ref() {
let id = TaskId::new("abc");
assert_eq!(id.to_string(), "abc");
assert_eq!(id.as_ref(), "abc");
}
#[test]
fn task_id_from_impls() {
let from_str: TaskId = "hello".into();
assert_eq!(from_str, TaskId::new("hello"));
let from_string: TaskId = String::from("world").into();
assert_eq!(from_string, TaskId::new("world"));
}
#[test]
fn context_id_display_and_as_ref() {
let id = ContextId::new("ctx");
assert_eq!(id.to_string(), "ctx");
assert_eq!(id.as_ref(), "ctx");
}
#[test]
fn context_id_from_impls() {
let from_str: ContextId = "c1".into();
assert_eq!(from_str, ContextId::new("c1"));
let from_string: ContextId = String::from("c2").into();
assert_eq!(from_string, ContextId::new("c2"));
}
#[test]
fn task_version_display() {
assert_eq!(TaskVersion::new(42).to_string(), "42");
assert_eq!(TaskVersion::new(0).to_string(), "0");
}
#[test]
fn task_version_from_u64() {
let v: TaskVersion = 99u64.into();
assert_eq!(v.get(), 99);
}
#[test]
fn task_status_with_timestamp_has_timestamp() {
let status = TaskStatus::with_timestamp(TaskState::Working);
assert!(
status.timestamp.is_some(),
"with_timestamp should set timestamp"
);
assert!(status.message.is_none());
assert_eq!(status.state, TaskState::Working);
}
#[test]
fn task_status_new_has_no_timestamp() {
let status = TaskStatus::new(TaskState::Submitted);
assert!(status.timestamp.is_none());
assert!(status.message.is_none());
assert_eq!(status.state, TaskState::Submitted);
}
#[test]
fn task_id_try_new_valid() {
let id = TaskId::try_new("task-1");
assert!(id.is_ok());
assert_eq!(id.unwrap(), TaskId::new("task-1"));
}
#[test]
fn task_id_try_new_valid_string() {
let id = TaskId::try_new("task-1".to_string());
assert!(id.is_ok());
assert_eq!(id.unwrap(), TaskId::new("task-1"));
}
#[test]
fn task_id_try_new_empty_rejected() {
let id = TaskId::try_new("");
assert!(id.is_err());
assert_eq!(
id.unwrap_err(),
"TaskId must not be empty or whitespace-only"
);
}
#[test]
fn task_id_try_new_whitespace_only_rejected() {
let id = TaskId::try_new(" ");
assert!(id.is_err());
}
#[test]
fn context_id_try_new_valid() {
let id = ContextId::try_new("ctx-1");
assert!(id.is_ok());
assert_eq!(id.unwrap(), ContextId::new("ctx-1"));
}
#[test]
fn context_id_try_new_valid_string() {
let id = ContextId::try_new("ctx-1".to_string());
assert!(id.is_ok());
assert_eq!(id.unwrap(), ContextId::new("ctx-1"));
}
#[test]
fn context_id_try_new_empty_rejected() {
let id = ContextId::try_new("");
assert!(id.is_err());
assert_eq!(
id.unwrap_err(),
"ContextId must not be empty or whitespace-only"
);
}
#[test]
fn context_id_try_new_whitespace_only_rejected() {
let id = ContextId::try_new(" \t ");
assert!(id.is_err());
}
#[test]
fn has_valid_timestamp_none_is_valid() {
let status = TaskStatus::new(TaskState::Working);
assert!(status.has_valid_timestamp());
}
#[test]
fn has_valid_timestamp_valid_iso8601() {
let status = TaskStatus {
state: TaskState::Working,
message: None,
timestamp: Some("2026-03-19T12:00:00Z".into()),
};
assert!(status.has_valid_timestamp());
}
#[test]
fn has_valid_timestamp_valid_with_offset() {
let status = TaskStatus {
state: TaskState::Working,
message: None,
timestamp: Some("2026-03-19T12:00:00+05:30".into()),
};
assert!(status.has_valid_timestamp());
}
#[test]
fn has_valid_timestamp_too_short() {
let status = TaskStatus {
state: TaskState::Working,
message: None,
timestamp: Some("2026-03-19".into()),
};
assert!(!status.has_valid_timestamp());
}
#[test]
fn has_valid_timestamp_missing_t_separator() {
let status = TaskStatus {
state: TaskState::Working,
message: None,
timestamp: Some("2026-03-19 12:00:00Z".into()),
};
assert!(!status.has_valid_timestamp());
}
#[test]
fn has_valid_timestamp_empty_string() {
let status = TaskStatus {
state: TaskState::Working,
message: None,
timestamp: Some(String::new()),
};
assert!(!status.has_valid_timestamp());
}
#[test]
fn has_valid_timestamp_with_timestamp_constructor() {
let status = TaskStatus::with_timestamp(TaskState::Completed);
assert!(status.has_valid_timestamp());
}
}