pe-tasks 0.1.0

Task management for Potential Expectations — structured work items, dependency DAG, lifecycle hooks, and agent tools
Documentation
//! # Task lifecycle — status transition validation and hooks.
//!
//! The `TaskLifecycle` trait provides hooks for task state transitions.
//! `DefaultLifecycle` validates transitions only (no side effects).
//! Users implement custom lifecycles for event emission, parent auto-completion, etc.
//!
//! ## Valid transitions
//!
//! ```text
//! Pending    → InProgress, Blocked, Cancelled
//! InProgress → Completed, Failed, Blocked, Cancelled
//! Blocked    → Pending, InProgress, Cancelled
//! Failed     → Pending (retry)
//! Completed  → (terminal)
//! Cancelled  → Pending (reopen)
//! ```

use pe_core::PeError;

use crate::task::{Task, TaskStatus};

/// Lifecycle hooks for task status transitions.
///
/// Implement this trait to add custom side effects (event emission,
/// parent auto-completion, metric tracking, etc.).
///
/// # Example
///
/// ```
/// use pe_tasks::lifecycle::{TaskLifecycle, DefaultLifecycle};
/// use pe_tasks::{Task, TaskStatus};
///
/// let lifecycle = DefaultLifecycle;
/// let task = Task::new("Test");
/// assert!(lifecycle.validate_transition(&task, &TaskStatus::InProgress).is_ok());
/// assert!(lifecycle.validate_transition(&task, &TaskStatus::Completed).is_err());
/// ```
pub trait TaskLifecycle: Send + Sync {
    /// Validate whether a transition is allowed. Return Err to reject.
    fn validate_transition(&self, task: &Task, new_status: &TaskStatus) -> Result<(), PeError>;

    /// Called after a task is created. Default: no-op.
    fn on_create(&self, _task: &Task) {}

    /// Called after a status transition succeeds. Default: no-op.
    fn on_transition(&self, _task: &Task, _old_status: &TaskStatus) {}

    /// Called after a task completes. Default: no-op.
    fn on_complete(&self, _task: &Task) {}

    /// Called after a task fails. Default: no-op.
    fn on_fail(&self, _task: &Task, _error: &str) {}

    /// Called after a task is cancelled. Default: no-op.
    fn on_cancel(&self, _task: &Task) {}
}

/// Default lifecycle: validates transitions only, no side effects.
///
/// Enforces the state machine defined in the module docs.
#[derive(Clone, Debug, Default)]
pub struct DefaultLifecycle;

impl DefaultLifecycle {
    /// Check if `from → to` is a valid transition.
    #[must_use]
    pub fn is_valid_transition(from: &TaskStatus, to: &TaskStatus) -> bool {
        matches!(
            (from, to),
            // From Pending
            (TaskStatus::Pending, TaskStatus::InProgress)
            | (TaskStatus::Pending, TaskStatus::Blocked)
            | (TaskStatus::Pending, TaskStatus::Cancelled)
            // From InProgress
            | (TaskStatus::InProgress, TaskStatus::Completed)
            | (TaskStatus::InProgress, TaskStatus::Failed)
            | (TaskStatus::InProgress, TaskStatus::Blocked)
            | (TaskStatus::InProgress, TaskStatus::Cancelled)
            // From Blocked
            | (TaskStatus::Blocked, TaskStatus::Pending)
            | (TaskStatus::Blocked, TaskStatus::InProgress)
            | (TaskStatus::Blocked, TaskStatus::Cancelled)
            // From Failed (retry)
            | (TaskStatus::Failed, TaskStatus::Pending)
            // From Cancelled (reopen)
            | (TaskStatus::Cancelled, TaskStatus::Pending)
        )
    }
}

impl TaskLifecycle for DefaultLifecycle {
    fn validate_transition(&self, task: &Task, new_status: &TaskStatus) -> Result<(), PeError> {
        if &task.status == new_status {
            return Ok(()); // no-op transition is always valid
        }
        if Self::is_valid_transition(&task.status, new_status) {
            Ok(())
        } else {
            Err(PeError::InvalidUpdate {
                details: format!(
                    "Invalid task transition: {:?}{:?} for task '{}'",
                    task.status, new_status, task.title
                ),
            })
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_valid_transitions_from_pending() {
        assert!(DefaultLifecycle::is_valid_transition(
            &TaskStatus::Pending,
            &TaskStatus::InProgress
        ));
        assert!(DefaultLifecycle::is_valid_transition(
            &TaskStatus::Pending,
            &TaskStatus::Blocked
        ));
        assert!(DefaultLifecycle::is_valid_transition(
            &TaskStatus::Pending,
            &TaskStatus::Cancelled
        ));
        // Invalid
        assert!(!DefaultLifecycle::is_valid_transition(
            &TaskStatus::Pending,
            &TaskStatus::Completed
        ));
        assert!(!DefaultLifecycle::is_valid_transition(
            &TaskStatus::Pending,
            &TaskStatus::Failed
        ));
    }

    #[test]
    fn test_valid_transitions_from_in_progress() {
        assert!(DefaultLifecycle::is_valid_transition(
            &TaskStatus::InProgress,
            &TaskStatus::Completed
        ));
        assert!(DefaultLifecycle::is_valid_transition(
            &TaskStatus::InProgress,
            &TaskStatus::Failed
        ));
        assert!(DefaultLifecycle::is_valid_transition(
            &TaskStatus::InProgress,
            &TaskStatus::Blocked
        ));
    }

    #[test]
    fn test_completed_is_terminal() {
        assert!(!DefaultLifecycle::is_valid_transition(
            &TaskStatus::Completed,
            &TaskStatus::Pending
        ));
        assert!(!DefaultLifecycle::is_valid_transition(
            &TaskStatus::Completed,
            &TaskStatus::InProgress
        ));
    }

    #[test]
    fn test_failed_can_retry() {
        assert!(DefaultLifecycle::is_valid_transition(
            &TaskStatus::Failed,
            &TaskStatus::Pending
        ));
        assert!(!DefaultLifecycle::is_valid_transition(
            &TaskStatus::Failed,
            &TaskStatus::InProgress
        ));
    }

    #[test]
    fn test_cancelled_can_reopen() {
        assert!(DefaultLifecycle::is_valid_transition(
            &TaskStatus::Cancelled,
            &TaskStatus::Pending
        ));
    }

    #[test]
    fn test_noop_transition_always_valid() {
        let lc = DefaultLifecycle;
        let task = Task::new("test");
        assert!(lc.validate_transition(&task, &TaskStatus::Pending).is_ok());
    }

    #[test]
    fn test_invalid_transition_returns_error() {
        let lc = DefaultLifecycle;
        let task = Task::new("test");
        let err = lc.validate_transition(&task, &TaskStatus::Completed);
        assert!(err.is_err());
    }
}