Skip to main content

mixtape_tools/process/
list_sessions.rs

1use crate::prelude::*;
2use crate::process::start_process::SESSION_MANAGER;
3
4/// Input for listing sessions (no parameters needed)
5#[derive(Debug, Deserialize, JsonSchema)]
6pub struct ListSessionsInput {}
7
8/// Tool for listing active process sessions
9pub struct ListSessionsTool;
10
11impl Tool for ListSessionsTool {
12    type Input = ListSessionsInput;
13
14    fn name(&self) -> &str {
15        "list_sessions"
16    }
17
18    fn description(&self) -> &str {
19        "List all active process sessions with their PIDs, commands, status, and runtime."
20    }
21
22    async fn execute(&self, _input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
23        let manager = SESSION_MANAGER.lock().await;
24        let sessions = manager.list_sessions().await;
25
26        if sessions.is_empty() {
27            return Ok("No active sessions".into());
28        }
29
30        let mut content = String::from("Active Sessions:\n\n");
31        content.push_str("PID    | STATUS              | RUNTIME | COMMAND\n");
32        content.push_str("-------|---------------------|---------|------------------\n");
33
34        for (pid, command, status, elapsed_ms) in sessions {
35            let runtime = if elapsed_ms < 1000 {
36                format!("{}ms", elapsed_ms)
37            } else if elapsed_ms < 60_000 {
38                format!("{:.1}s", elapsed_ms as f64 / 1000.0)
39            } else {
40                format!("{:.1}m", elapsed_ms as f64 / 60_000.0)
41            };
42
43            let status_str = format!("{:?}", status);
44            let cmd_preview = if command.len() > 30 {
45                format!("{}...", &command[..27])
46            } else {
47                command
48            };
49
50            content.push_str(&format!(
51                "{:<6} | {:<19} | {:<7} | {}\n",
52                pid, status_str, runtime, cmd_preview
53            ));
54        }
55
56        Ok(content.into())
57    }
58
59    fn format_output_plain(&self, result: &ToolResult) -> String {
60        let output = result.as_text();
61        if output == "No active sessions" {
62            return output.to_string();
63        }
64
65        let lines: Vec<&str> = output.lines().collect();
66        let mut out = String::from("Sessions\n");
67        out.push_str(&"─".repeat(60));
68        out.push('\n');
69
70        for line in lines.iter().skip(4) {
71            let parts: Vec<&str> = line.split('|').collect();
72            if parts.len() >= 4 {
73                let (pid, status, runtime, command) = (
74                    parts[0].trim(),
75                    parts[1].trim(),
76                    parts[2].trim(),
77                    parts[3].trim(),
78                );
79                let status_icon = if status.contains("Running") {
80                    "●"
81                } else if status.contains("Completed") {
82                    "✓"
83                } else {
84                    "○"
85                };
86                out.push_str(&format!(
87                    "{} [{}] {} - {} ({})\n",
88                    status_icon,
89                    pid,
90                    command,
91                    status,
92                    format_runtime_nice(runtime)
93                ));
94            }
95        }
96        out
97    }
98
99    fn format_output_ansi(&self, result: &ToolResult) -> String {
100        let output = result.as_text();
101        if output == "No active sessions" {
102            return format!("\x1b[2m{}\x1b[0m", output);
103        }
104
105        let lines: Vec<&str> = output.lines().collect();
106        let mut out = String::from("\x1b[1mSessions\x1b[0m\n");
107        out.push_str(&format!("\x1b[2m{}\x1b[0m\n", "─".repeat(60)));
108
109        for line in lines.iter().skip(4) {
110            let parts: Vec<&str> = line.split('|').collect();
111            if parts.len() >= 4 {
112                let (pid, status, runtime, command) = (
113                    parts[0].trim(),
114                    parts[1].trim(),
115                    parts[2].trim(),
116                    parts[3].trim(),
117                );
118                let (status_icon, status_color) = if status.contains("Running") {
119                    ("\x1b[32m●\x1b[0m", "\x1b[32m")
120                } else if status.contains("Completed") {
121                    ("\x1b[34m✓\x1b[0m", "\x1b[34m")
122                } else if status.contains("Failed") || status.contains("Error") {
123                    ("\x1b[31m✗\x1b[0m", "\x1b[31m")
124                } else {
125                    ("\x1b[33m○\x1b[0m", "\x1b[33m")
126                };
127                out.push_str(&format!(
128                    "{} \x1b[36m[{}]\x1b[0m {} {}{}\x1b[0m \x1b[2m({})\x1b[0m\n",
129                    status_icon,
130                    pid,
131                    command,
132                    status_color,
133                    status,
134                    format_runtime_nice(runtime)
135                ));
136            }
137        }
138        out
139    }
140
141    fn format_output_markdown(&self, result: &ToolResult) -> String {
142        let output = result.as_text();
143        if output == "No active sessions" {
144            return format!("*{}*", output);
145        }
146
147        let lines: Vec<&str> = output.lines().collect();
148        let mut out = String::from("### Sessions\n\n| Status | PID | Command | Runtime |\n|--------|-----|---------|--------|\n");
149
150        for line in lines.iter().skip(4) {
151            let parts: Vec<&str> = line.split('|').collect();
152            if parts.len() >= 4 {
153                let (pid, status, runtime, command) = (
154                    parts[0].trim(),
155                    parts[1].trim(),
156                    parts[2].trim(),
157                    parts[3].trim(),
158                );
159                let status_emoji = if status.contains("Running") {
160                    "🟢"
161                } else if status.contains("Completed") {
162                    "🔵"
163                } else if status.contains("Failed") || status.contains("Error") {
164                    "🔴"
165                } else {
166                    "🟡"
167                };
168                out.push_str(&format!(
169                    "| {} {} | {} | `{}` | {} |\n",
170                    status_emoji,
171                    status,
172                    pid,
173                    command,
174                    format_runtime_nice(runtime)
175                ));
176            }
177        }
178        out
179    }
180}
181
182/// Format runtime in human-friendly form
183fn format_runtime_nice(runtime_str: &str) -> String {
184    // Parse the existing format (Xms, X.Xs, X.Xm)
185    let s = runtime_str.trim();
186    if s.ends_with("ms") {
187        s.to_string()
188    } else if s.ends_with('s') {
189        let secs: f64 = s.trim_end_matches('s').parse().unwrap_or(0.0);
190        if secs < 60.0 {
191            format!("{:.0}s", secs)
192        } else {
193            let mins = (secs / 60.0).floor();
194            let remaining_secs = secs % 60.0;
195            format!("{}m {:02.0}s", mins as u32, remaining_secs)
196        }
197    } else if s.ends_with('m') {
198        let mins: f64 = s.trim_end_matches('m').parse().unwrap_or(0.0);
199        if mins < 60.0 {
200            format!("{:.0}m", mins)
201        } else {
202            let hours = (mins / 60.0).floor();
203            let remaining_mins = mins % 60.0;
204            format!("{}h {:02.0}m", hours as u32, remaining_mins)
205        }
206    } else {
207        s.to_string()
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::process::start_process::{StartProcessInput, StartProcessTool};
215    use mixtape_core::ToolResult;
216
217    #[tokio::test]
218    async fn test_list_sessions_empty() {
219        let tool = ListSessionsTool;
220        let input = ListSessionsInput {};
221
222        let result = tool.execute(input).await;
223        assert!(result.is_ok());
224
225        let output = result.unwrap().as_text();
226        // May have "No active sessions" or show existing sessions from other tests
227        assert!(!output.is_empty());
228    }
229
230    #[tokio::test]
231    async fn test_list_sessions_with_processes() {
232        // Start a couple of processes
233        let start_tool = StartProcessTool;
234
235        let input1 = StartProcessInput {
236            command: "echo 'session 1'".to_string(),
237            timeout_ms: Some(5000),
238            shell: None,
239        };
240
241        let input2 = StartProcessInput {
242            command: "sleep 5".to_string(),
243            timeout_ms: Some(10000),
244            shell: None,
245        };
246
247        // Start first process
248        let result1 = start_tool.execute(input1).await;
249        if result1.is_err() {
250            // Skip if process creation fails
251            return;
252        }
253
254        // Start second process
255        let _ = start_tool.execute(input2).await;
256
257        // Now list sessions
258        let list_tool = ListSessionsTool;
259        let list_input = ListSessionsInput {};
260
261        let result = list_tool.execute(list_input).await;
262        assert!(result.is_ok());
263
264        let output = result.unwrap().as_text();
265        // Should show session header
266        assert!(output.contains("PID"));
267        assert!(output.contains("STATUS"));
268        assert!(output.contains("RUNTIME"));
269        assert!(output.contains("COMMAND"));
270    }
271
272    #[tokio::test]
273    async fn test_list_sessions_shows_runtime() {
274        let start_tool = StartProcessTool;
275        let input = StartProcessInput {
276            command: "sleep 2".to_string(),
277            timeout_ms: Some(5000),
278            shell: None,
279        };
280
281        let start_result = start_tool.execute(input).await;
282        if start_result.is_err() {
283            return;
284        }
285
286        // Wait a moment for runtime to accumulate
287        tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
288
289        let list_tool = ListSessionsTool;
290        let list_input = ListSessionsInput {};
291
292        let result = list_tool.execute(list_input).await;
293        assert!(result.is_ok());
294
295        let output = result.unwrap().as_text();
296        // Should show some runtime (ms or s)
297        assert!(output.contains("ms") || output.contains("s") || output.contains("m"));
298    }
299
300    // ==================== format_runtime_nice tests ====================
301
302    #[test]
303    fn test_format_runtime_nice_milliseconds() {
304        assert_eq!(format_runtime_nice("500ms"), "500ms");
305        assert_eq!(format_runtime_nice("100ms"), "100ms");
306    }
307
308    #[test]
309    fn test_format_runtime_nice_seconds() {
310        assert_eq!(format_runtime_nice("5.0s"), "5s");
311        // Note: f64 uses banker's rounding, so 30.5 rounds to 30 (nearest even)
312        assert_eq!(format_runtime_nice("30.5s"), "30s");
313        assert_eq!(format_runtime_nice("30.6s"), "31s");
314    }
315
316    #[test]
317    fn test_format_runtime_nice_seconds_to_minutes() {
318        // 90 seconds = 1m 30s
319        assert_eq!(format_runtime_nice("90.0s"), "1m 30s");
320        // 120 seconds = 2m 00s
321        assert_eq!(format_runtime_nice("120.0s"), "2m 00s");
322    }
323
324    #[test]
325    fn test_format_runtime_nice_minutes() {
326        assert_eq!(format_runtime_nice("5.0m"), "5m");
327        assert_eq!(format_runtime_nice("45.0m"), "45m");
328    }
329
330    #[test]
331    fn test_format_runtime_nice_minutes_to_hours() {
332        // 90 minutes = 1h 30m
333        assert_eq!(format_runtime_nice("90.0m"), "1h 30m");
334        // 120 minutes = 2h 00m
335        assert_eq!(format_runtime_nice("120.0m"), "2h 00m");
336    }
337
338    #[test]
339    fn test_format_runtime_nice_unknown_format() {
340        assert_eq!(format_runtime_nice("unknown"), "unknown");
341        assert_eq!(format_runtime_nice("5h"), "5h");
342    }
343
344    #[test]
345    fn test_format_runtime_nice_trimming() {
346        assert_eq!(format_runtime_nice("  500ms  "), "500ms");
347    }
348
349    // ==================== format_output tests ====================
350
351    #[test]
352    fn test_format_output_plain_no_sessions() {
353        let tool = ListSessionsTool;
354        let result: ToolResult = "No active sessions".into();
355
356        let formatted = tool.format_output_plain(&result);
357        assert_eq!(formatted, "No active sessions");
358    }
359
360    #[test]
361    fn test_format_output_plain_with_sessions() {
362        let tool = ListSessionsTool;
363        let result: ToolResult = "Active Sessions:\n\nPID    | STATUS              | RUNTIME | COMMAND\n-------|---------------------|---------|------------------\n12345  | Running             | 500ms   | echo hello".into();
364
365        let formatted = tool.format_output_plain(&result);
366
367        assert!(formatted.contains("Sessions"));
368        assert!(formatted.contains("12345"));
369        assert!(formatted.contains("●") || formatted.contains("✓") || formatted.contains("○"));
370    }
371
372    #[test]
373    fn test_format_output_ansi_no_sessions() {
374        let tool = ListSessionsTool;
375        let result: ToolResult = "No active sessions".into();
376
377        let formatted = tool.format_output_ansi(&result);
378        assert!(formatted.contains("\x1b[2m")); // dim
379        assert!(formatted.contains("No active sessions"));
380    }
381
382    #[test]
383    fn test_format_output_ansi_with_sessions() {
384        let tool = ListSessionsTool;
385        let result: ToolResult = "Active Sessions:\n\nPID    | STATUS              | RUNTIME | COMMAND\n-------|---------------------|---------|------------------\n12345  | Running             | 500ms   | sleep 10".into();
386
387        let formatted = tool.format_output_ansi(&result);
388
389        assert!(formatted.contains("\x1b[")); // ANSI codes
390        assert!(formatted.contains("\x1b[1m")); // bold for header
391        assert!(formatted.contains("\x1b[32m")); // green for running
392    }
393
394    #[test]
395    fn test_format_output_ansi_status_colors() {
396        let tool = ListSessionsTool;
397
398        // Running = green
399        let running: ToolResult = "Active Sessions:\n\nPID    | STATUS              | RUNTIME | COMMAND\n-------|---------------------|---------|------------------\n1      | Running             | 1ms     | cmd".into();
400        let formatted = tool.format_output_ansi(&running);
401        assert!(formatted.contains("\x1b[32m")); // green
402
403        // Completed = blue
404        let completed: ToolResult = "Active Sessions:\n\nPID    | STATUS              | RUNTIME | COMMAND\n-------|---------------------|---------|------------------\n1      | Completed           | 1ms     | cmd".into();
405        let formatted = tool.format_output_ansi(&completed);
406        assert!(formatted.contains("\x1b[34m")); // blue
407    }
408
409    #[test]
410    fn test_format_output_markdown_no_sessions() {
411        let tool = ListSessionsTool;
412        let result: ToolResult = "No active sessions".into();
413
414        let formatted = tool.format_output_markdown(&result);
415        assert_eq!(formatted, "*No active sessions*");
416    }
417
418    #[test]
419    fn test_format_output_markdown_with_sessions() {
420        let tool = ListSessionsTool;
421        let result: ToolResult = "Active Sessions:\n\nPID    | STATUS              | RUNTIME | COMMAND\n-------|---------------------|---------|------------------\n12345  | Running             | 500ms   | echo hello".into();
422
423        let formatted = tool.format_output_markdown(&result);
424
425        assert!(formatted.contains("### Sessions"));
426        assert!(formatted.contains("| Status |"));
427        assert!(formatted.contains("🟢 Running")); // green circle for running
428    }
429
430    #[test]
431    fn test_format_output_markdown_status_emojis() {
432        let tool = ListSessionsTool;
433
434        // Running = green circle
435        let running: ToolResult = "Active Sessions:\n\nPID    | STATUS              | RUNTIME | COMMAND\n-------|---------------------|---------|------------------\n1      | Running             | 1ms     | cmd".into();
436        assert!(tool.format_output_markdown(&running).contains("🟢"));
437
438        // Completed = blue circle
439        let completed: ToolResult = "Active Sessions:\n\nPID    | STATUS              | RUNTIME | COMMAND\n-------|---------------------|---------|------------------\n1      | Completed           | 1ms     | cmd".into();
440        assert!(tool.format_output_markdown(&completed).contains("🔵"));
441
442        // Error = red circle
443        let error: ToolResult = "Active Sessions:\n\nPID    | STATUS              | RUNTIME | COMMAND\n-------|---------------------|---------|------------------\n1      | Failed              | 1ms     | cmd".into();
444        assert!(tool.format_output_markdown(&error).contains("🔴"));
445    }
446
447    // ==================== Tool metadata tests ====================
448
449    #[test]
450    fn test_tool_name() {
451        let tool = ListSessionsTool;
452        assert_eq!(tool.name(), "list_sessions");
453    }
454
455    #[test]
456    fn test_tool_description() {
457        let tool = ListSessionsTool;
458        assert!(!tool.description().is_empty());
459        assert!(tool.description().contains("session"));
460    }
461}