Skip to main content

pitchfork_cli/
daemon_status.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, strum::Display, strum::EnumIs)]
4#[strum(serialize_all = "snake_case")]
5#[serde(rename_all = "snake_case")]
6#[derive(Default)]
7pub enum DaemonStatus {
8    Failed(String),
9    Waiting,
10    Running,
11    Stopping,
12    /// The supervisor is restarting the daemon (a watched file changed, or a
13    /// `restart`/`start --force` replaced it): the old process is being or
14    /// has been stopped and the new one has not started yet.
15    ///
16    /// Not a final state: it becomes `running`/`waiting` once the new process
17    /// starts, or a terminal status if the restart fails. `pitchfork wait`
18    /// keeps following the daemon through it.
19    Restarting,
20    /// Exit code of the process, or -1 if unknown.
21    Errored(i32),
22    /// A `oneshot = true` daemon whose process ran to completion with exit
23    /// code 0.
24    ///
25    /// Distinct from `Stopped` so the CLI, the TUI and the web UI can tell
26    /// "finished its work" from "never ran" or "was interrupted", and so a
27    /// failed run stays distinguishable from a successful one.
28    ///
29    /// It does not mark the task as permanently done: a start re-runs a
30    /// completed oneshot, including when it is reached as a dependency, which
31    /// is why the guide requires the command to be idempotent. What waits on
32    /// a oneshot is the start that is running it, not this status.
33    Completed,
34    #[default]
35    Stopped,
36}
37
38impl DaemonStatus {
39    pub fn style(&self) -> String {
40        let s = self.to_string();
41        match self {
42            DaemonStatus::Failed(_) => console::style(s).red().to_string(),
43            DaemonStatus::Waiting => console::style(s).yellow().to_string(),
44            DaemonStatus::Running => console::style(s).green().to_string(),
45            DaemonStatus::Stopping => console::style(s).yellow().to_string(),
46            DaemonStatus::Restarting => console::style(s).yellow().to_string(),
47            DaemonStatus::Stopped => console::style(s).dim().to_string(),
48            DaemonStatus::Completed => console::style(s).green().dim().to_string(),
49            DaemonStatus::Errored(_) => console::style(s).red().to_string(),
50        }
51    }
52
53    /// Whether the daemon's process is being, or has just been, stopped on
54    /// purpose: a plain stop in flight (`stopping`) or a restart.
55    pub fn is_stopping_or_restarting(&self) -> bool {
56        self.is_stopping() || self.is_restarting()
57    }
58
59    pub fn error_message(&self) -> Option<String> {
60        match self {
61            DaemonStatus::Failed(msg) => Some(msg.clone()),
62            DaemonStatus::Errored(code) if *code != -1 => Some(format!("exit code {code}")),
63            DaemonStatus::Errored(_) => Some("unknown exit code".to_string()),
64            _ => None,
65        }
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    fn all_variants() -> Vec<(&'static str, DaemonStatus)> {
74        vec![
75            ("running", DaemonStatus::Running),
76            ("stopped", DaemonStatus::Stopped),
77            ("waiting", DaemonStatus::Waiting),
78            ("stopping", DaemonStatus::Stopping),
79            ("restarting", DaemonStatus::Restarting),
80            ("failed", DaemonStatus::Failed("some error".to_string())),
81            ("errored", DaemonStatus::Errored(1)),
82            ("errored_unknown", DaemonStatus::Errored(-1)),
83            ("completed", DaemonStatus::Completed),
84        ]
85    }
86
87    #[test]
88    fn test_completed_serializes_as_completed() {
89        // `mise daemons ls` and other consumers read this string; keep it
90        // stable and distinct from "stopped".
91        assert_eq!(DaemonStatus::Completed.to_string(), "completed");
92        assert_eq!(
93            serde_json::to_string(&DaemonStatus::Completed).unwrap(),
94            "\"completed\""
95        );
96    }
97
98    #[test]
99    fn test_restarting_serializes_as_restarting() {
100        assert_eq!(DaemonStatus::Restarting.to_string(), "restarting");
101        assert_eq!(
102            serde_json::to_string(&DaemonStatus::Restarting).unwrap(),
103            "\"restarting\""
104        );
105        assert!(DaemonStatus::Restarting.error_message().is_none());
106        assert!(DaemonStatus::Restarting.is_stopping_or_restarting());
107        assert!(!DaemonStatus::Running.is_stopping_or_restarting());
108    }
109
110    #[test]
111    fn test_completed_has_no_error_message() {
112        assert!(DaemonStatus::Completed.error_message().is_none());
113    }
114
115    #[test]
116    fn test_daemon_status_json_roundtrip() {
117        for (name, status) in all_variants() {
118            let json_str = serde_json::to_string(&status)
119                .unwrap_or_else(|_| panic!("Failed to serialize {name}"));
120            let result: Result<DaemonStatus, _> = serde_json::from_str(&json_str);
121            assert!(
122                result.is_ok(),
123                "Failed to deserialize {name}: {:?}",
124                result.err()
125            );
126        }
127    }
128
129    #[test]
130    fn test_daemon_status_toml_roundtrip() {
131        #[derive(Serialize, Deserialize, Debug)]
132        struct Wrapper {
133            status: DaemonStatus,
134        }
135
136        for (name, status) in all_variants() {
137            let w = Wrapper { status };
138            let toml_str =
139                toml::to_string(&w).unwrap_or_else(|e| panic!("Failed to serialize {name}: {e}"));
140            let result: Result<Wrapper, _> = toml::from_str(&toml_str);
141            assert!(
142                result.is_ok(),
143                "Failed to deserialize {name}: {:?}\nTOML was: {toml_str:?}",
144                result.err()
145            );
146        }
147    }
148}