rusmes_cli/commands/
status.rs1use 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#[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
32fn 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 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
67pub 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 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
187pub fn run(runtime_dir: &str, json: bool) -> Result<()> {
189 let output = render(runtime_dir, json)?;
190 print!("{}", output);
191 Ok(())
192}
193
194fn 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
206fn 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 Ok(false)
218 }
219}
220
221fn 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
231fn 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 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 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
276fn 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); assert_eq!(uptime, "3d 0h 0m 0s");
350 }
351
352 #[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 #[test]
366 fn color_disabled_when_no_color_env() {
367 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 assert!(
377 !output.contains('\x1b'),
378 "output should not contain ANSI escapes when color is disabled"
379 );
380
381 colored::control::unset_override();
383 }
384}