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