1use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum TaskStatus {
10 Pending,
11 InProgress,
12 Completed,
13 Deleted,
14}
15
16impl TaskStatus {
17 pub fn can_transition_to(self, target: TaskStatus) -> bool {
23 if target == TaskStatus::Deleted {
24 return true;
25 }
26 matches!(
27 (self, target),
28 (TaskStatus::Pending, TaskStatus::InProgress)
29 | (TaskStatus::InProgress, TaskStatus::Completed)
30 )
31 }
32}
33
34impl std::fmt::Display for TaskStatus {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 match self {
37 TaskStatus::Pending => write!(f, "pending"),
38 TaskStatus::InProgress => write!(f, "in_progress"),
39 TaskStatus::Completed => write!(f, "completed"),
40 TaskStatus::Deleted => write!(f, "deleted"),
41 }
42 }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct TaskFile {
49 pub id: String,
50 pub subject: String,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub description: Option<String>,
54
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub active_form: Option<String>,
57
58 pub status: TaskStatus,
59
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub owner: Option<String>,
62
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub blocks: Vec<String>,
66
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub blocked_by: Vec<String>,
70
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub metadata: Option<serde_json::Value>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct CreateTaskRequest {
80 pub subject: String,
81
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub description: Option<String>,
84
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub active_form: Option<String>,
87
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub metadata: Option<serde_json::Value>,
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct TaskUpdate {
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub subject: Option<String>,
98
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub description: Option<String>,
101
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub active_form: Option<String>,
104
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub status: Option<TaskStatus>,
107
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub owner: Option<String>,
110
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub add_blocks: Option<Vec<String>>,
114
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub add_blocked_by: Option<Vec<String>>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub metadata: Option<serde_json::Value>,
122}
123
124#[derive(Debug, Clone, Default)]
126pub struct TaskFilter {
127 pub status: Option<TaskStatus>,
128 pub owner: Option<String>,
129 pub unblocked_only: bool,
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn status_transitions() {
139 assert!(TaskStatus::Pending.can_transition_to(TaskStatus::InProgress));
140 assert!(TaskStatus::InProgress.can_transition_to(TaskStatus::Completed));
141 assert!(!TaskStatus::Pending.can_transition_to(TaskStatus::Completed));
142 assert!(!TaskStatus::Completed.can_transition_to(TaskStatus::InProgress));
143
144 assert!(TaskStatus::Pending.can_transition_to(TaskStatus::Deleted));
146 assert!(TaskStatus::InProgress.can_transition_to(TaskStatus::Deleted));
147 assert!(TaskStatus::Completed.can_transition_to(TaskStatus::Deleted));
148 }
149
150 #[test]
151 fn serde_round_trip_task() {
152 let task = TaskFile {
153 id: "1".into(),
154 subject: "Fix auth bug".into(),
155 description: Some("The login endpoint returns 500".into()),
156 active_form: Some("Fixing auth bug".into()),
157 status: TaskStatus::Pending,
158 owner: None,
159 blocks: vec!["2".into()],
160 blocked_by: vec![],
161 metadata: None,
162 };
163
164 let json = serde_json::to_string_pretty(&task).unwrap();
165 let parsed: TaskFile = serde_json::from_str(&json).unwrap();
166 assert_eq!(parsed.id, "1");
167 assert_eq!(parsed.blocks, vec!["2"]);
168 }
169
170 #[test]
171 fn deserialize_claude_code_format() {
172 let json = r#"{
173 "id": "42",
174 "subject": "Implement feature",
175 "description": "Add caching",
176 "activeForm": "Implementing feature",
177 "status": "in_progress",
178 "owner": "coder",
179 "blocks": [],
180 "blockedBy": ["41"]
181 }"#;
182
183 let task: TaskFile = serde_json::from_str(json).unwrap();
184 assert_eq!(task.id, "42");
185 assert_eq!(task.status, TaskStatus::InProgress);
186 assert_eq!(task.owner.as_deref(), Some("coder"));
187 assert_eq!(task.blocked_by, vec!["41"]);
188 }
189}