Skip to main content

zeph_core/goal/
state.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Goal status FSM with valid transition table.
5
6/// Status of a long-horizon goal.
7///
8/// Transitions form a directed acyclic graph where `Completed` and `Cleared`
9/// are terminal states. `/goal create` is NOT a transition — it inserts a new row.
10///
11/// ```text
12/// Active ──► Paused ──► Active
13///   │         │
14///   ▼         ▼
15/// Completed  Cleared
16/// Active ──► Completed
17/// Active ──► Cleared
18/// ```
19#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum GoalStatus {
22    /// Goal is being actively tracked and injected into context.
23    Active,
24    /// Goal is paused — not injected into context, resumable.
25    Paused,
26    /// Goal was marked as achieved. Terminal state.
27    Completed,
28    /// Goal was dismissed without completion. Terminal state.
29    Cleared,
30}
31
32impl GoalStatus {
33    /// Return whether `self → to` is a valid FSM transition.
34    ///
35    /// # Examples
36    ///
37    /// ```rust
38    /// use zeph_core::goal::GoalStatus;
39    ///
40    /// assert!(GoalStatus::Active.can_transition_to(GoalStatus::Paused));
41    /// assert!(!GoalStatus::Completed.can_transition_to(GoalStatus::Active));
42    /// ```
43    #[must_use]
44    pub fn can_transition_to(self, to: Self) -> bool {
45        matches!(
46            (self, to),
47            (Self::Active, Self::Paused | Self::Completed | Self::Cleared)
48                | (Self::Paused, Self::Active | Self::Cleared)
49        )
50    }
51
52    /// Return `true` if this status accepts no further transitions.
53    ///
54    /// # Examples
55    ///
56    /// ```rust
57    /// use zeph_core::goal::GoalStatus;
58    ///
59    /// assert!(GoalStatus::Completed.is_terminal());
60    /// assert!(!GoalStatus::Active.is_terminal());
61    /// ```
62    #[must_use]
63    pub fn is_terminal(self) -> bool {
64        matches!(self, Self::Completed | Self::Cleared)
65    }
66
67    /// Short ASCII symbol used in TUI status badge.
68    #[must_use]
69    pub fn badge_symbol(self) -> &'static str {
70        match self {
71            Self::Active => "▶",
72            Self::Paused => "⏸",
73            Self::Completed => "✓",
74            Self::Cleared => "✗",
75        }
76    }
77}
78
79impl std::fmt::Display for GoalStatus {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        let s = match self {
82            Self::Active => "active",
83            Self::Paused => "paused",
84            Self::Completed => "completed",
85            Self::Cleared => "cleared",
86        };
87        f.write_str(s)
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn valid_transitions() {
97        assert!(GoalStatus::Active.can_transition_to(GoalStatus::Paused));
98        assert!(GoalStatus::Active.can_transition_to(GoalStatus::Completed));
99        assert!(GoalStatus::Active.can_transition_to(GoalStatus::Cleared));
100        assert!(GoalStatus::Paused.can_transition_to(GoalStatus::Active));
101        assert!(GoalStatus::Paused.can_transition_to(GoalStatus::Cleared));
102    }
103
104    #[test]
105    fn terminal_states_reject_transitions() {
106        for from in [GoalStatus::Completed, GoalStatus::Cleared] {
107            for to in [
108                GoalStatus::Active,
109                GoalStatus::Paused,
110                GoalStatus::Completed,
111                GoalStatus::Cleared,
112            ] {
113                assert!(
114                    !from.can_transition_to(to),
115                    "{from:?} -> {to:?} should be invalid"
116                );
117            }
118        }
119    }
120
121    #[test]
122    fn is_terminal() {
123        assert!(GoalStatus::Completed.is_terminal());
124        assert!(GoalStatus::Cleared.is_terminal());
125        assert!(!GoalStatus::Active.is_terminal());
126        assert!(!GoalStatus::Paused.is_terminal());
127    }
128}