Skip to main content

pe_tasks/
lifecycle.rs

1//! # Task lifecycle — status transition validation and hooks.
2//!
3//! The `TaskLifecycle` trait provides hooks for task state transitions.
4//! `DefaultLifecycle` validates transitions only (no side effects).
5//! Users implement custom lifecycles for event emission, parent auto-completion, etc.
6//!
7//! ## Valid transitions
8//!
9//! ```text
10//! Pending    → InProgress, Blocked, Cancelled
11//! InProgress → Completed, Failed, Blocked, Cancelled
12//! Blocked    → Pending, InProgress, Cancelled
13//! Failed     → Pending (retry)
14//! Completed  → (terminal)
15//! Cancelled  → Pending (reopen)
16//! ```
17
18use pe_core::PeError;
19
20use crate::task::{Task, TaskStatus};
21
22/// Lifecycle hooks for task status transitions.
23///
24/// Implement this trait to add custom side effects (event emission,
25/// parent auto-completion, metric tracking, etc.).
26///
27/// # Example
28///
29/// ```
30/// use pe_tasks::lifecycle::{TaskLifecycle, DefaultLifecycle};
31/// use pe_tasks::{Task, TaskStatus};
32///
33/// let lifecycle = DefaultLifecycle;
34/// let task = Task::new("Test");
35/// assert!(lifecycle.validate_transition(&task, &TaskStatus::InProgress).is_ok());
36/// assert!(lifecycle.validate_transition(&task, &TaskStatus::Completed).is_err());
37/// ```
38pub trait TaskLifecycle: Send + Sync {
39    /// Validate whether a transition is allowed. Return Err to reject.
40    fn validate_transition(&self, task: &Task, new_status: &TaskStatus) -> Result<(), PeError>;
41
42    /// Called after a task is created. Default: no-op.
43    fn on_create(&self, _task: &Task) {}
44
45    /// Called after a status transition succeeds. Default: no-op.
46    fn on_transition(&self, _task: &Task, _old_status: &TaskStatus) {}
47
48    /// Called after a task completes. Default: no-op.
49    fn on_complete(&self, _task: &Task) {}
50
51    /// Called after a task fails. Default: no-op.
52    fn on_fail(&self, _task: &Task, _error: &str) {}
53
54    /// Called after a task is cancelled. Default: no-op.
55    fn on_cancel(&self, _task: &Task) {}
56}
57
58/// Default lifecycle: validates transitions only, no side effects.
59///
60/// Enforces the state machine defined in the module docs.
61#[derive(Clone, Debug, Default)]
62pub struct DefaultLifecycle;
63
64impl DefaultLifecycle {
65    /// Check if `from → to` is a valid transition.
66    #[must_use]
67    pub fn is_valid_transition(from: &TaskStatus, to: &TaskStatus) -> bool {
68        matches!(
69            (from, to),
70            // From Pending
71            (TaskStatus::Pending, TaskStatus::InProgress)
72            | (TaskStatus::Pending, TaskStatus::Blocked)
73            | (TaskStatus::Pending, TaskStatus::Cancelled)
74            // From InProgress
75            | (TaskStatus::InProgress, TaskStatus::Completed)
76            | (TaskStatus::InProgress, TaskStatus::Failed)
77            | (TaskStatus::InProgress, TaskStatus::Blocked)
78            | (TaskStatus::InProgress, TaskStatus::Cancelled)
79            // From Blocked
80            | (TaskStatus::Blocked, TaskStatus::Pending)
81            | (TaskStatus::Blocked, TaskStatus::InProgress)
82            | (TaskStatus::Blocked, TaskStatus::Cancelled)
83            // From Failed (retry)
84            | (TaskStatus::Failed, TaskStatus::Pending)
85            // From Cancelled (reopen)
86            | (TaskStatus::Cancelled, TaskStatus::Pending)
87        )
88    }
89}
90
91impl TaskLifecycle for DefaultLifecycle {
92    fn validate_transition(&self, task: &Task, new_status: &TaskStatus) -> Result<(), PeError> {
93        if &task.status == new_status {
94            return Ok(()); // no-op transition is always valid
95        }
96        if Self::is_valid_transition(&task.status, new_status) {
97            Ok(())
98        } else {
99            Err(PeError::InvalidUpdate {
100                details: format!(
101                    "Invalid task transition: {:?} → {:?} for task '{}'",
102                    task.status, new_status, task.title
103                ),
104            })
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn test_valid_transitions_from_pending() {
115        assert!(DefaultLifecycle::is_valid_transition(
116            &TaskStatus::Pending,
117            &TaskStatus::InProgress
118        ));
119        assert!(DefaultLifecycle::is_valid_transition(
120            &TaskStatus::Pending,
121            &TaskStatus::Blocked
122        ));
123        assert!(DefaultLifecycle::is_valid_transition(
124            &TaskStatus::Pending,
125            &TaskStatus::Cancelled
126        ));
127        // Invalid
128        assert!(!DefaultLifecycle::is_valid_transition(
129            &TaskStatus::Pending,
130            &TaskStatus::Completed
131        ));
132        assert!(!DefaultLifecycle::is_valid_transition(
133            &TaskStatus::Pending,
134            &TaskStatus::Failed
135        ));
136    }
137
138    #[test]
139    fn test_valid_transitions_from_in_progress() {
140        assert!(DefaultLifecycle::is_valid_transition(
141            &TaskStatus::InProgress,
142            &TaskStatus::Completed
143        ));
144        assert!(DefaultLifecycle::is_valid_transition(
145            &TaskStatus::InProgress,
146            &TaskStatus::Failed
147        ));
148        assert!(DefaultLifecycle::is_valid_transition(
149            &TaskStatus::InProgress,
150            &TaskStatus::Blocked
151        ));
152    }
153
154    #[test]
155    fn test_completed_is_terminal() {
156        assert!(!DefaultLifecycle::is_valid_transition(
157            &TaskStatus::Completed,
158            &TaskStatus::Pending
159        ));
160        assert!(!DefaultLifecycle::is_valid_transition(
161            &TaskStatus::Completed,
162            &TaskStatus::InProgress
163        ));
164    }
165
166    #[test]
167    fn test_failed_can_retry() {
168        assert!(DefaultLifecycle::is_valid_transition(
169            &TaskStatus::Failed,
170            &TaskStatus::Pending
171        ));
172        assert!(!DefaultLifecycle::is_valid_transition(
173            &TaskStatus::Failed,
174            &TaskStatus::InProgress
175        ));
176    }
177
178    #[test]
179    fn test_cancelled_can_reopen() {
180        assert!(DefaultLifecycle::is_valid_transition(
181            &TaskStatus::Cancelled,
182            &TaskStatus::Pending
183        ));
184    }
185
186    #[test]
187    fn test_noop_transition_always_valid() {
188        let lc = DefaultLifecycle;
189        let task = Task::new("test");
190        assert!(lc.validate_transition(&task, &TaskStatus::Pending).is_ok());
191    }
192
193    #[test]
194    fn test_invalid_transition_returns_error() {
195        let lc = DefaultLifecycle;
196        let task = Task::new("test");
197        let err = lc.validate_transition(&task, &TaskStatus::Completed);
198        assert!(err.is_err());
199    }
200}