1#![cfg_attr(not(test), allow(dead_code))]
3
4use std::collections::HashSet;
5
6use serde::{Deserialize, Serialize};
7
8#[cfg(test)]
9use super::review::MergeDisposition;
10use super::review::ReviewState;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
13#[serde(rename_all = "lowercase")]
14pub enum TaskState {
15 #[default]
16 Backlog,
17 Todo,
18 #[serde(rename = "in_progress")]
19 InProgress,
20 Review,
21 Blocked,
22 Done,
23 Archived,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ReviewDisposition {
29 Approved,
30 ChangesRequested,
31 Rejected,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
35pub struct WorkflowMeta {
36 #[serde(default)]
37 pub state: TaskState,
38 #[serde(default)]
39 pub execution_owner: Option<String>,
40 #[serde(default)]
41 pub review_owner: Option<String>,
42 #[serde(default)]
43 pub depends_on: Vec<u32>,
44 #[serde(default)]
45 pub blocked_on: Option<String>,
46 #[serde(default)]
47 pub worktree_path: Option<String>,
48 #[serde(default)]
49 pub branch: Option<String>,
50 #[serde(default)]
51 pub commit: Option<String>,
52 #[serde(default)]
53 pub artifacts: Vec<String>,
54 #[serde(default)]
55 pub review_disposition: Option<ReviewDisposition>,
56 #[serde(default)]
57 pub review: Option<ReviewState>,
58 #[serde(default)]
59 pub next_action: Option<String>,
60}
61
62impl WorkflowMeta {
63 pub fn is_runnable(&self, done_tasks: &HashSet<u32>) -> bool {
64 self.state == TaskState::Todo
65 && self
66 .depends_on
67 .iter()
68 .all(|dependency| done_tasks.contains(dependency))
69 }
70
71 pub fn transition(&mut self, to: TaskState) -> Result<(), String> {
72 can_transition(self.state, to)?;
73 self.state = to;
74 Ok(())
75 }
76}
77
78pub fn can_transition(from: TaskState, to: TaskState) -> Result<(), String> {
79 if from == to {
80 return Ok(());
81 }
82
83 let allowed = matches!(
84 (from, to),
85 (TaskState::Backlog, TaskState::Todo)
86 | (TaskState::Backlog, TaskState::Archived)
87 | (TaskState::Todo, TaskState::Backlog)
88 | (TaskState::Todo, TaskState::InProgress)
89 | (TaskState::Todo, TaskState::Blocked)
90 | (TaskState::Todo, TaskState::Archived)
91 | (TaskState::InProgress, TaskState::Todo)
92 | (TaskState::InProgress, TaskState::Review)
93 | (TaskState::InProgress, TaskState::Blocked)
94 | (TaskState::Review, TaskState::InProgress)
95 | (TaskState::Review, TaskState::Blocked)
96 | (TaskState::Review, TaskState::Done)
97 | (TaskState::Review, TaskState::Archived)
98 | (TaskState::Blocked, TaskState::Todo)
99 | (TaskState::Blocked, TaskState::InProgress)
100 | (TaskState::Blocked, TaskState::Archived)
101 | (TaskState::Done, TaskState::Archived)
102 );
103
104 if allowed {
105 Ok(())
106 } else {
107 Err(format!("illegal task state transition: {from:?} -> {to:?}"))
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn default_workflow_meta_has_backlog_state() {
117 let meta = WorkflowMeta::default();
118 assert_eq!(meta.state, TaskState::Backlog);
119 assert!(meta.depends_on.is_empty());
120 assert!(meta.artifacts.is_empty());
121 }
122
123 #[test]
124 fn is_runnable_requires_todo_state_and_completed_dependencies() {
125 let mut done_tasks = HashSet::from([1, 2]);
126 let runnable = WorkflowMeta {
127 state: TaskState::Todo,
128 depends_on: vec![1, 2],
129 ..WorkflowMeta::default()
130 };
131 assert!(runnable.is_runnable(&done_tasks));
132
133 done_tasks.remove(&2);
134 assert!(!runnable.is_runnable(&done_tasks));
135
136 let wrong_state = WorkflowMeta {
137 state: TaskState::InProgress,
138 depends_on: vec![1],
139 ..WorkflowMeta::default()
140 };
141 assert!(!wrong_state.is_runnable(&HashSet::from([1])));
142 }
143
144 #[test]
145 fn legal_transitions_pass() {
146 let legal = [
147 (TaskState::Backlog, TaskState::Todo),
148 (TaskState::Backlog, TaskState::Archived),
149 (TaskState::Todo, TaskState::InProgress),
150 (TaskState::Todo, TaskState::Blocked),
151 (TaskState::InProgress, TaskState::Review),
152 (TaskState::InProgress, TaskState::Blocked),
153 (TaskState::Review, TaskState::Done),
154 (TaskState::Review, TaskState::InProgress),
155 (TaskState::Review, TaskState::Archived),
156 (TaskState::Blocked, TaskState::Todo),
157 (TaskState::Done, TaskState::Archived),
158 ];
159
160 for (from, to) in legal {
161 assert!(can_transition(from, to).is_ok());
162 }
163 }
164
165 #[test]
166 fn illegal_transitions_fail() {
167 let illegal = [
168 (TaskState::Backlog, TaskState::Done),
169 (TaskState::Backlog, TaskState::Review),
170 (TaskState::Todo, TaskState::Done),
171 (TaskState::InProgress, TaskState::Done),
172 (TaskState::Done, TaskState::Todo),
173 (TaskState::Archived, TaskState::Todo),
174 ];
175
176 for (from, to) in illegal {
177 assert!(can_transition(from, to).is_err());
178 }
179 }
180
181 #[test]
182 fn transition_updates_state_after_validation() {
183 let mut meta = WorkflowMeta {
184 state: TaskState::Todo,
185 ..WorkflowMeta::default()
186 };
187
188 meta.transition(TaskState::InProgress).unwrap();
189 assert_eq!(meta.state, TaskState::InProgress);
190 assert!(meta.transition(TaskState::Archived).is_err());
191 assert_eq!(meta.state, TaskState::InProgress);
192 }
193
194 #[test]
195 fn serde_round_trip_preserves_workflow_meta() {
196 let meta = WorkflowMeta {
197 state: TaskState::InProgress,
198 execution_owner: Some("eng-1-2".to_string()),
199 review_owner: Some("manager-1".to_string()),
200 depends_on: vec![7, 8],
201 blocked_on: Some("waiting for api".to_string()),
202 worktree_path: Some("/tmp/eng-1-2".to_string()),
203 branch: Some("eng-1-2/task-19".to_string()),
204 commit: Some("abc1234".to_string()),
205 artifacts: vec!["artifacts/test.log".to_string()],
206 review_disposition: Some(ReviewDisposition::ChangesRequested),
207 review: Some(ReviewState {
208 reviewer: "manager-1".to_string(),
209 packet_ref: Some("review/packet-7.json".to_string()),
210 disposition: MergeDisposition::ReworkRequired,
211 notes: Some("needs another pass".to_string()),
212 reviewed_at: None,
213 nudge_sent: false,
214 }),
215 next_action: Some("address review feedback".to_string()),
216 };
217
218 let json = serde_json::to_string(&meta).unwrap();
219 assert!(json.contains("\"state\":\"in_progress\""));
220 assert!(json.contains("\"review_disposition\":\"changes_requested\""));
221 assert!(json.contains("\"packet_ref\":\"review/packet-7.json\""));
222 assert!(json.contains("\"disposition\":\"rework_required\""));
223
224 let decoded: WorkflowMeta = serde_json::from_str(&json).unwrap();
225 assert_eq!(decoded, meta);
226 }
227
228 #[test]
231 fn self_transition_is_allowed() {
232 assert!(can_transition(TaskState::Backlog, TaskState::Backlog).is_ok());
233 assert!(can_transition(TaskState::InProgress, TaskState::InProgress).is_ok());
234 assert!(can_transition(TaskState::Done, TaskState::Done).is_ok());
235 }
236
237 #[test]
240 fn archived_cannot_transition_to_any_state() {
241 let targets = [
242 TaskState::Backlog,
243 TaskState::Todo,
244 TaskState::InProgress,
245 TaskState::Review,
246 TaskState::Blocked,
247 TaskState::Done,
248 ];
249 for target in targets {
250 assert!(
251 can_transition(TaskState::Archived, target).is_err(),
252 "archived -> {target:?} should be illegal"
253 );
254 }
255 }
256
257 #[test]
260 fn transition_error_message_contains_states() {
261 let err = can_transition(TaskState::Backlog, TaskState::Done).unwrap_err();
262 assert!(err.contains("Backlog"));
263 assert!(err.contains("Done"));
264 }
265
266 #[test]
269 fn is_runnable_with_no_deps_at_todo() {
270 let meta = WorkflowMeta {
271 state: TaskState::Todo,
272 ..WorkflowMeta::default()
273 };
274 assert!(meta.is_runnable(&HashSet::new()));
275 }
276
277 #[test]
278 fn is_runnable_at_backlog_is_false() {
279 let meta = WorkflowMeta::default(); assert!(!meta.is_runnable(&HashSet::new()));
281 }
282
283 #[test]
284 fn is_runnable_at_done_is_false() {
285 let meta = WorkflowMeta {
286 state: TaskState::Done,
287 ..WorkflowMeta::default()
288 };
289 assert!(!meta.is_runnable(&HashSet::new()));
290 }
291
292 #[test]
295 fn full_lifecycle_transition_chain() {
296 let mut meta = WorkflowMeta::default(); meta.transition(TaskState::Todo).unwrap();
298 meta.transition(TaskState::InProgress).unwrap();
299 meta.transition(TaskState::Review).unwrap();
300 meta.transition(TaskState::Done).unwrap();
301 meta.transition(TaskState::Archived).unwrap();
302 assert_eq!(meta.state, TaskState::Archived);
303 }
304
305 #[test]
306 fn blocked_to_todo_to_in_progress_chain() {
307 let mut meta = WorkflowMeta {
308 state: TaskState::Todo,
309 ..WorkflowMeta::default()
310 };
311 meta.transition(TaskState::Blocked).unwrap();
312 meta.transition(TaskState::Todo).unwrap();
313 meta.transition(TaskState::InProgress).unwrap();
314 assert_eq!(meta.state, TaskState::InProgress);
315 }
316
317 #[test]
318 fn review_rework_cycle() {
319 let mut meta = WorkflowMeta {
320 state: TaskState::InProgress,
321 ..WorkflowMeta::default()
322 };
323 meta.transition(TaskState::Review).unwrap();
324 meta.transition(TaskState::InProgress).unwrap(); meta.transition(TaskState::Review).unwrap();
326 meta.transition(TaskState::Done).unwrap();
327 assert_eq!(meta.state, TaskState::Done);
328 }
329
330 #[test]
333 fn task_state_default_is_backlog() {
334 let state: TaskState = Default::default();
335 assert_eq!(state, TaskState::Backlog);
336 }
337
338 #[test]
339 fn task_state_serde_round_trip() {
340 let states = [
341 TaskState::Backlog,
342 TaskState::Todo,
343 TaskState::InProgress,
344 TaskState::Review,
345 TaskState::Blocked,
346 TaskState::Done,
347 TaskState::Archived,
348 ];
349 for state in states {
350 let json = serde_json::to_string(&state).unwrap();
351 let decoded: TaskState = serde_json::from_str(&json).unwrap();
352 assert_eq!(decoded, state);
353 }
354 }
355
356 #[test]
359 fn review_disposition_serde_round_trip() {
360 let dispositions = [
361 ReviewDisposition::Approved,
362 ReviewDisposition::ChangesRequested,
363 ReviewDisposition::Rejected,
364 ];
365 for disp in dispositions {
366 let json = serde_json::to_string(&disp).unwrap();
367 let decoded: ReviewDisposition = serde_json::from_str(&json).unwrap();
368 assert_eq!(decoded, disp);
369 }
370 }
371}