Skip to main content

batty_cli/team/
workflow.rs

1//! Workflow state model for Batty-managed tasks.
2#![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    // --- self-transition ---
229
230    #[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    // --- archived is terminal ---
238
239    #[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    // --- transition error message ---
258
259    #[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    // --- is_runnable edge cases ---
267
268    #[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(); // Backlog
280        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    // --- multi-step transitions ---
293
294    #[test]
295    fn full_lifecycle_transition_chain() {
296        let mut meta = WorkflowMeta::default(); // Backlog
297        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(); // rework
325        meta.transition(TaskState::Review).unwrap();
326        meta.transition(TaskState::Done).unwrap();
327        assert_eq!(meta.state, TaskState::Done);
328    }
329
330    // --- TaskState serde ---
331
332    #[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    // --- ReviewDisposition serde ---
357
358    #[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}