Skip to main content

rusmes_cli/commands/
status.rs

1//! Check server status
2
3use anyhow::{Context, Result};
4use colored::*;
5use serde::Serialize;
6use std::collections::HashMap;
7use std::fs;
8use std::net::TcpStream;
9use std::path::Path;
10use std::time::Duration;
11
12#[cfg(target_os = "linux")]
13use std::io::Read;
14
15const SMTP_DEFAULT_PORT: u16 = 25;
16const IMAP_DEFAULT_PORT: u16 = 143;
17const METRICS_DEFAULT_PORT: u16 = 9090;
18
19/// Serialisable status snapshot used for `--json` output.
20#[derive(Debug, Serialize)]
21pub struct ServerStatus {
22    pub running: bool,
23    pub pid: Option<u32>,
24    pub uptime_secs: Option<u64>,
25    pub smtp_listening: bool,
26    pub imap_listening: bool,
27    pub pid_file_path: String,
28    pub status_message: String,
29    pub active_connections: Option<HashMap<String, i64>>,
30}
31
32/// Query the metrics endpoint and extract active connection counts per protocol.
33///
34/// Returns `None` if the endpoint is unavailable (server not running, metrics
35/// disabled, or network error).
36fn fetch_active_connections(metrics_port: u16) -> Option<HashMap<String, i64>> {
37    let url = format!("http://127.0.0.1:{}/metrics", metrics_port);
38    let resp = reqwest::blocking::get(&url).ok()?;
39    if !resp.status().is_success() {
40        return None;
41    }
42    let body = resp.text().ok()?;
43    let mut map = HashMap::new();
44    for line in body.lines() {
45        // Match lines like: rusmes_active_connections{protocol="smtp"} 2
46        if line.starts_with("rusmes_active_connections{") {
47            if let Some(rest) = line.strip_prefix("rusmes_active_connections{protocol=\"") {
48                if let Some(end) = rest.find('"') {
49                    let protocol = &rest[..end];
50                    let after = &rest[end..];
51                    if let Some(val_str) = after.split('}').nth(1) {
52                        if let Ok(count) = val_str.trim().parse::<i64>() {
53                            map.insert(protocol.to_string(), count);
54                        }
55                    }
56                }
57            }
58        }
59    }
60    if map.is_empty() {
61        None
62    } else {
63        Some(map)
64    }
65}
66
67/// Render a status frame as a human-readable `String` (used by `--watch`).
68///
69/// When `json` is `true`, the returned string is JSON-formatted.
70pub fn render(runtime_dir: &str, json: bool) -> Result<String> {
71    let pid_file = format!("{}/rusmes.pid", runtime_dir);
72    let pid = check_pid_file(&pid_file)?;
73
74    let process_running = if let Some(p) = pid {
75        check_process_running(p)?
76    } else {
77        false
78    };
79
80    let smtp_listening = check_port_listening("127.0.0.1", SMTP_DEFAULT_PORT);
81    let imap_listening = check_port_listening("127.0.0.1", IMAP_DEFAULT_PORT);
82
83    let uptime_secs = if process_running {
84        pid.and_then(|p| get_process_uptime(p).ok())
85    } else {
86        None
87    };
88
89    let status_message = if process_running {
90        "RUNNING".to_string()
91    } else if pid.is_some() {
92        "STOPPED (stale PID file)".to_string()
93    } else {
94        "Server may not be running (PID file not found)".to_string()
95    };
96
97    let active_connections = if process_running {
98        fetch_active_connections(METRICS_DEFAULT_PORT)
99    } else {
100        None
101    };
102
103    let snapshot = ServerStatus {
104        running: process_running,
105        pid,
106        uptime_secs,
107        smtp_listening,
108        imap_listening,
109        pid_file_path: pid_file.clone(),
110        status_message: status_message.clone(),
111        active_connections: active_connections.clone(),
112    };
113
114    if json {
115        return Ok(serde_json::to_string_pretty(&snapshot)?);
116    }
117
118    // Human-readable rendering.
119    let mut out = String::new();
120    out.push_str("Checking RusMES server status...\n\n");
121    out.push_str("Server status:\n");
122
123    if process_running {
124        out.push_str(&format!("  Status: {}\n", "RUNNING".green().bold()));
125        if let Some(p) = pid {
126            out.push_str(&format!("  PID: {}\n", p));
127        }
128    } else if pid.is_some() {
129        out.push_str(&format!(
130            "  Status: {}\n",
131            "STOPPED (stale PID file)".yellow()
132        ));
133    } else {
134        out.push_str(&format!(
135            "  Status: {}\n",
136            "Server may not be running (PID file not found)".yellow()
137        ));
138    }
139
140    out.push_str(&format!("  PID file: {}\n", pid_file));
141    out.push('\n');
142    out.push_str("Service status:\n");
143    out.push_str(&format!(
144        "  SMTP (port {}): {}\n",
145        SMTP_DEFAULT_PORT,
146        if smtp_listening {
147            "listening".green().to_string()
148        } else {
149            "not listening".red().to_string()
150        }
151    ));
152    out.push_str(&format!(
153        "  IMAP (port {}): {}\n",
154        IMAP_DEFAULT_PORT,
155        if imap_listening {
156            "listening".green().to_string()
157        } else {
158            "not listening".red().to_string()
159        }
160    ));
161
162    if let Some(uptime) = uptime_secs {
163        out.push_str(&format!("\nUptime: {}\n", format_uptime(uptime)));
164    }
165
166    if process_running {
167        match active_connections {
168            Some(ref conns) => {
169                out.push_str("\nActive connections:\n");
170                let mut protocols: Vec<_> = conns.iter().collect();
171                protocols.sort_by_key(|(k, _)| k.as_str());
172                for (proto, count) in protocols {
173                    out.push_str(&format!("  {}: {}\n", proto, count));
174                }
175            }
176            None => {
177                out.push_str(
178                    "\nActive connections: unavailable (metrics endpoint not responding)\n",
179                );
180            }
181        }
182    }
183
184    Ok(out)
185}
186
187/// Check server status and print to stdout.
188pub fn run(runtime_dir: &str, json: bool) -> Result<()> {
189    let output = render(runtime_dir, json)?;
190    print!("{}", output);
191    Ok(())
192}
193
194/// Check PID file and return PID if exists
195fn check_pid_file(pid_file: &str) -> Result<Option<u32>> {
196    let path = Path::new(pid_file);
197    if !path.exists() {
198        return Ok(None);
199    }
200
201    let content = fs::read_to_string(path).context("Failed to read PID file")?;
202    let pid: u32 = content.trim().parse().context("Invalid PID in PID file")?;
203    Ok(Some(pid))
204}
205
206/// Check if process with given PID is running
207fn check_process_running(_pid: u32) -> Result<bool> {
208    #[cfg(target_os = "linux")]
209    {
210        let proc_path = format!("/proc/{}", _pid);
211        Ok(Path::new(&proc_path).exists())
212    }
213
214    #[cfg(not(target_os = "linux"))]
215    {
216        // Signal 0 check is not portable without libc — return false conservatively.
217        Ok(false)
218    }
219}
220
221/// Check if a port is listening
222fn check_port_listening(host: &str, port: u16) -> bool {
223    let address = format!("{}:{}", host, port);
224    address
225        .parse()
226        .ok()
227        .map(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(100)).is_ok())
228        .unwrap_or(false)
229}
230
231/// Get process uptime in seconds
232fn get_process_uptime(_pid: u32) -> Result<u64> {
233    #[cfg(target_os = "linux")]
234    {
235        let stat_path = format!("/proc/{}/stat", _pid);
236        let mut file = fs::File::open(&stat_path).context("Failed to open process stat file")?;
237
238        let mut content = String::new();
239        file.read_to_string(&mut content)
240            .context("Failed to read process stat file")?;
241
242        // Parse stat file - start time is the 22nd field
243        let fields: Vec<&str> = content.split_whitespace().collect();
244        if fields.len() < 22 {
245            anyhow::bail!("Invalid stat file format");
246        }
247
248        let start_time: u64 = fields[21]
249            .parse()
250            .context("Failed to parse process start time")?;
251
252        let uptime_content =
253            fs::read_to_string("/proc/uptime").context("Failed to read system uptime")?;
254        let uptime_fields: Vec<&str> = uptime_content.split_whitespace().collect();
255        let system_uptime: f64 = uptime_fields
256            .first()
257            .ok_or_else(|| anyhow::anyhow!("Empty /proc/uptime"))?
258            .parse()
259            .context("Failed to parse system uptime")?;
260
261        // Clock ticks per second (usually 100 on Linux).
262        let clock_ticks: u64 = 100;
263        let start_time_seconds = start_time / clock_ticks;
264        let current_time = system_uptime as u64;
265        let process_uptime = current_time.saturating_sub(start_time_seconds);
266
267        Ok(process_uptime)
268    }
269
270    #[cfg(not(target_os = "linux"))]
271    {
272        Ok(0)
273    }
274}
275
276/// Format uptime in human-readable format
277fn format_uptime(seconds: u64) -> String {
278    let days = seconds / 86400;
279    let hours = (seconds % 86400) / 3600;
280    let minutes = (seconds % 3600) / 60;
281    let secs = seconds % 60;
282
283    if days > 0 {
284        format!("{}d {}h {}m {}s", days, hours, minutes, secs)
285    } else if hours > 0 {
286        format!("{}h {}m {}s", hours, minutes, secs)
287    } else if minutes > 0 {
288        format!("{}m {}s", minutes, secs)
289    } else {
290        format!("{}s", secs)
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_format_uptime_seconds() {
300        let uptime = format_uptime(45);
301        assert_eq!(uptime, "45s");
302    }
303
304    #[test]
305    fn test_format_uptime_minutes() {
306        let uptime = format_uptime(150);
307        assert_eq!(uptime, "2m 30s");
308    }
309
310    #[test]
311    fn test_format_uptime_hours() {
312        let uptime = format_uptime(3665);
313        assert_eq!(uptime, "1h 1m 5s");
314    }
315
316    #[test]
317    fn test_format_uptime_days() {
318        let uptime = format_uptime(90061);
319        assert_eq!(uptime, "1d 1h 1m 1s");
320    }
321
322    #[test]
323    fn test_format_uptime_zero() {
324        let uptime = format_uptime(0);
325        assert_eq!(uptime, "0s");
326    }
327
328    #[test]
329    fn test_format_uptime_exact_minute() {
330        let uptime = format_uptime(60);
331        assert_eq!(uptime, "1m 0s");
332    }
333
334    #[test]
335    fn test_format_uptime_exact_hour() {
336        let uptime = format_uptime(3600);
337        assert_eq!(uptime, "1h 0m 0s");
338    }
339
340    #[test]
341    fn test_format_uptime_exact_day() {
342        let uptime = format_uptime(86400);
343        assert_eq!(uptime, "1d 0h 0m 0s");
344    }
345
346    #[test]
347    fn test_format_uptime_multiple_days() {
348        let uptime = format_uptime(259200); // 3 days
349        assert_eq!(uptime, "3d 0h 0m 0s");
350    }
351
352    /// `status --json` should produce parseable JSON even when the server is
353    /// not running.
354    #[test]
355    fn json_output_parses_as_json() {
356        let tmp = std::env::temp_dir().join("rusmes_status_test_no_pid_dir");
357        let dir_str = tmp.to_string_lossy().to_string();
358
359        let output = render(&dir_str, true).expect("render should not error");
360        let _: serde_json::Value =
361            serde_json::from_str(&output).expect("status --json should produce parseable JSON");
362    }
363
364    /// When `NO_COLOR` is set, the text output should not contain ANSI escapes.
365    #[test]
366    fn color_disabled_when_no_color_env() {
367        // Force color off for this test.
368        colored::control::set_override(false);
369
370        let tmp = std::env::temp_dir().join("rusmes_status_no_color_test");
371        let dir_str = tmp.to_string_lossy().to_string();
372
373        let output = render(&dir_str, false).expect("render should not error");
374
375        // ANSI escape sequences start with ESC (\x1b).
376        assert!(
377            !output.contains('\x1b'),
378            "output should not contain ANSI escapes when color is disabled"
379        );
380
381        // Restore so other tests are not affected.
382        colored::control::unset_override();
383    }
384}