Skip to main content

mixtape_tools/process/
read_process_output.rs

1use crate::prelude::*;
2use crate::process::start_process::SESSION_MANAGER;
3
4/// Input for reading process output
5#[derive(Debug, Deserialize, JsonSchema)]
6pub struct ReadProcessOutputInput {
7    /// Process ID to read from
8    pub pid: u32,
9
10    /// Clear the output buffer after reading (default: false)
11    #[serde(default)]
12    pub clear_buffer: bool,
13
14    /// Maximum time to wait for new output in milliseconds (default: 5000)
15    #[serde(default = "default_timeout")]
16    pub timeout_ms: u64,
17}
18
19fn default_timeout() -> u64 {
20    5000
21}
22
23/// Tool for reading output from a running process
24pub struct ReadProcessOutputTool;
25
26impl Tool for ReadProcessOutputTool {
27    type Input = ReadProcessOutputInput;
28
29    fn name(&self) -> &str {
30        "read_process_output"
31    }
32
33    fn description(&self) -> &str {
34        "Read accumulated output from a running process. Can optionally clear the buffer after reading."
35    }
36
37    fn format_output_plain(&self, result: &ToolResult) -> String {
38        let text = result.as_text();
39        let (pid, status, lines) = parse_process_output(&text);
40
41        let mut out = String::new();
42        out.push_str(&"─".repeat(50));
43        out.push('\n');
44        if let Some(p) = pid {
45            out.push_str(&format!("  Process {}", p));
46        }
47        if let Some(s) = status {
48            out.push_str(&format!(" [{}]", s));
49        }
50        out.push_str(&format!("\n{}\n", "─".repeat(50)));
51
52        if lines.is_empty() {
53            out.push_str("  (no output)\n");
54        } else {
55            let width = lines.len().to_string().len().max(3);
56            for (i, line) in lines.iter().enumerate() {
57                out.push_str(&format!("  {:>width$} │ {}\n", i + 1, line, width = width));
58            }
59        }
60        out
61    }
62
63    fn format_output_ansi(&self, result: &ToolResult) -> String {
64        let text = result.as_text();
65        let (pid, status, lines) = parse_process_output(&text);
66
67        let mut out = String::new();
68        out.push_str(&format!("\x1b[2m{}\x1b[0m\n", "─".repeat(50)));
69
70        let (icon, status_color) = match status {
71            Some(s) if s.contains("Running") => ("\x1b[32m●\x1b[0m", "\x1b[32m"),
72            Some(s) if s.contains("Completed") => ("\x1b[34m●\x1b[0m", "\x1b[34m"),
73            Some(s) if s.contains("Waiting") => ("\x1b[33m●\x1b[0m", "\x1b[33m"),
74            _ => ("\x1b[2m●\x1b[0m", "\x1b[2m"),
75        };
76
77        out.push_str(&format!("  {} ", icon));
78        if let Some(p) = pid {
79            out.push_str(&format!("\x1b[1mProcess {}\x1b[0m", p));
80        }
81        if let Some(s) = status {
82            out.push_str(&format!(" {}{}\x1b[0m", status_color, s));
83        }
84        out.push_str(&format!("\n\x1b[2m{}\x1b[0m\n", "─".repeat(50)));
85
86        if lines.is_empty() {
87            out.push_str("  \x1b[2m(no output)\x1b[0m\n");
88        } else {
89            let width = lines.len().to_string().len().max(3);
90            for (i, line) in lines.iter().enumerate() {
91                out.push_str(&format!(
92                    "  \x1b[36m{:>width$}\x1b[0m \x1b[2m│\x1b[0m {}\n",
93                    i + 1,
94                    line,
95                    width = width
96                ));
97            }
98        }
99        out
100    }
101
102    fn format_output_markdown(&self, result: &ToolResult) -> String {
103        let text = result.as_text();
104        let (pid, status, lines) = parse_process_output(&text);
105
106        let mut out = String::new();
107        let status_emoji = match status {
108            Some(s) if s.contains("Running") => "🟢",
109            Some(s) if s.contains("Completed") => "🔵",
110            Some(s) if s.contains("Waiting") => "🟡",
111            _ => "⚪",
112        };
113
114        if let Some(p) = pid {
115            out.push_str(&format!("### {} Process {}", status_emoji, p));
116        }
117        if let Some(s) = status {
118            out.push_str(&format!(" - {}", s));
119        }
120        out.push_str("\n\n");
121
122        if lines.is_empty() {
123            out.push_str("*No output*\n");
124        } else {
125            out.push_str("```\n");
126            for line in lines {
127                out.push_str(line);
128                out.push('\n');
129            }
130            out.push_str("```\n");
131        }
132        out
133    }
134
135    async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
136        let manager = SESSION_MANAGER.lock().await;
137
138        // Check if session exists
139        if manager.get_session(input.pid).await.is_none() {
140            return Err(format!("Process {} not found", input.pid).into());
141        }
142
143        // Wait for potential new output
144        drop(manager);
145        tokio::time::sleep(tokio::time::Duration::from_millis(
146            input.timeout_ms.min(10000),
147        ))
148        .await;
149        let manager = SESSION_MANAGER.lock().await;
150
151        let output = manager.read_output(input.pid, input.clear_buffer).await?;
152        let status = manager.check_status(input.pid).await?;
153
154        let content = if output.is_empty() {
155            format!("Process {}\nStatus: {:?}\nNo new output", input.pid, status)
156        } else {
157            let mut result = format!(
158                "Process {}\nStatus: {:?}\n\nOutput ({} lines):\n",
159                input.pid,
160                status,
161                output.len()
162            );
163
164            for line in &output {
165                result.push_str(&format!("{}\n", line));
166            }
167
168            result
169        };
170
171        Ok(content.into())
172    }
173}
174
175/// Parse read_process_output result
176fn parse_process_output(output: &str) -> (Option<&str>, Option<&str>, Vec<&str>) {
177    let mut pid = None;
178    let mut status = None;
179    let mut lines = Vec::new();
180    let mut in_output = false;
181
182    for line in output.lines() {
183        if line.starts_with("Process ") && !line.contains("Output") {
184            pid = line.split_whitespace().nth(1);
185        } else if line.starts_with("Status:") {
186            status = Some(line.trim_start_matches("Status:").trim());
187        } else if line.contains("Output (") || line == "No new output" {
188            in_output = true;
189            if line == "No new output" {
190                // Don't set in_output, just note there's no output
191            }
192        } else if in_output {
193            lines.push(line);
194        }
195    }
196
197    (pid, status, lines)
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::process::start_process::{StartProcessInput, StartProcessTool};
204    use mixtape_core::ToolResult;
205
206    #[tokio::test]
207    async fn test_read_process_output_nonexistent() {
208        let tool = ReadProcessOutputTool;
209
210        let input = ReadProcessOutputInput {
211            pid: 99999999,
212            clear_buffer: false,
213            timeout_ms: 100,
214        };
215
216        let result = tool.execute(input).await;
217        assert!(result.is_err());
218        assert!(result.unwrap_err().to_string().contains("not found"));
219    }
220
221    #[tokio::test]
222    async fn test_read_process_output_basic() {
223        // Start a process first
224        let start_tool = StartProcessTool;
225        let start_input = StartProcessInput {
226            command: "echo 'test output'".to_string(),
227            timeout_ms: Some(5000),
228            shell: None,
229        };
230
231        let start_result = start_tool.execute(start_input).await;
232        if start_result.is_err() {
233            // Skip if process creation fails (might happen in CI)
234            return;
235        }
236
237        let start_output = start_result.unwrap().as_text();
238        // Extract PID from output
239        if let Some(pid_line) = start_output.lines().find(|l| l.contains("PID:")) {
240            if let Some(pid_str) = pid_line.split(':').nth(1) {
241                if let Ok(pid) = pid_str.trim().parse::<u32>() {
242                    // Now read the output
243                    let read_tool = ReadProcessOutputTool;
244                    let read_input = ReadProcessOutputInput {
245                        pid,
246                        clear_buffer: false,
247                        timeout_ms: 100,
248                    };
249
250                    let result = read_tool.execute(read_input).await;
251                    assert!(result.is_ok());
252                    return;
253                }
254            }
255        }
256
257        // If we couldn't parse PID, that's okay - the test is about error handling
258    }
259
260    // ==================== parse_process_output tests ====================
261
262    #[test]
263    fn test_parse_process_output_complete() {
264        let output = "Process 12345\nStatus: Running\n\nOutput (3 lines):\nline1\nline2\nline3";
265        let (pid, status, lines) = parse_process_output(output);
266
267        assert_eq!(pid, Some("12345"));
268        assert_eq!(status, Some("Running"));
269        assert_eq!(lines, vec!["line1", "line2", "line3"]);
270    }
271
272    #[test]
273    fn test_parse_process_output_no_output() {
274        let output = "Process 12345\nStatus: Running\nNo new output";
275        let (pid, status, lines) = parse_process_output(output);
276
277        assert_eq!(pid, Some("12345"));
278        assert_eq!(status, Some("Running"));
279        assert!(lines.is_empty());
280    }
281
282    #[test]
283    fn test_parse_process_output_empty() {
284        let output = "";
285        let (pid, status, lines) = parse_process_output(output);
286
287        assert_eq!(pid, None);
288        assert_eq!(status, None);
289        assert!(lines.is_empty());
290    }
291
292    #[test]
293    fn test_parse_process_output_completed_status() {
294        let output =
295            "Process 999\nStatus: Completed { exit_code: Some(0) }\n\nOutput (1 lines):\ndone";
296        let (pid, status, lines) = parse_process_output(output);
297
298        assert_eq!(pid, Some("999"));
299        assert_eq!(status, Some("Completed { exit_code: Some(0) }"));
300        assert_eq!(lines, vec!["done"]);
301    }
302
303    #[test]
304    fn test_parse_process_output_multiline() {
305        let output = "Process 1\nStatus: Running\n\nOutput (5 lines):\na\nb\nc\nd\ne";
306        let (_, _, lines) = parse_process_output(output);
307
308        assert_eq!(lines.len(), 5);
309    }
310
311    // ==================== format_output tests ====================
312
313    #[test]
314    fn test_format_output_plain_with_output() {
315        let tool = ReadProcessOutputTool;
316        let result: ToolResult =
317            "Process 12345\nStatus: Running\n\nOutput (2 lines):\nHello\nWorld".into();
318
319        let formatted = tool.format_output_plain(&result);
320
321        assert!(formatted.contains("Process 12345"));
322        assert!(formatted.contains("Running"));
323        assert!(formatted.contains("Hello"));
324        assert!(formatted.contains("World"));
325        assert!(formatted.contains("│")); // line number separator
326    }
327
328    #[test]
329    fn test_format_output_plain_no_output() {
330        let tool = ReadProcessOutputTool;
331        let result: ToolResult = "Process 12345\nStatus: Running\nNo new output".into();
332
333        let formatted = tool.format_output_plain(&result);
334
335        assert!(formatted.contains("(no output)"));
336    }
337
338    #[test]
339    fn test_format_output_ansi_running() {
340        let tool = ReadProcessOutputTool;
341        let result: ToolResult = "Process 12345\nStatus: Running\n\nOutput (1 lines):\ntest".into();
342
343        let formatted = tool.format_output_ansi(&result);
344
345        assert!(formatted.contains("\x1b[")); // ANSI codes present
346        assert!(formatted.contains("\x1b[32m")); // green for running
347    }
348
349    #[test]
350    fn test_format_output_ansi_completed() {
351        let tool = ReadProcessOutputTool;
352        let result: ToolResult =
353            "Process 12345\nStatus: Completed\n\nOutput (1 lines):\ndone".into();
354
355        let formatted = tool.format_output_ansi(&result);
356
357        assert!(formatted.contains("\x1b[34m")); // blue for completed
358    }
359
360    #[test]
361    fn test_format_output_ansi_waiting() {
362        let tool = ReadProcessOutputTool;
363        let result: ToolResult =
364            "Process 12345\nStatus: WaitingForInput\n\nOutput (1 lines):\n>>> ".into();
365
366        let formatted = tool.format_output_ansi(&result);
367
368        assert!(formatted.contains("\x1b[33m")); // yellow for waiting
369    }
370
371    #[test]
372    fn test_format_output_markdown_with_output() {
373        let tool = ReadProcessOutputTool;
374        let result: ToolResult =
375            "Process 12345\nStatus: Running\n\nOutput (2 lines):\nline1\nline2".into();
376
377        let formatted = tool.format_output_markdown(&result);
378
379        assert!(formatted.contains("### 🟢 Process 12345")); // green circle for running
380        assert!(formatted.contains("```"));
381        assert!(formatted.contains("line1"));
382    }
383
384    #[test]
385    fn test_format_output_markdown_no_output() {
386        let tool = ReadProcessOutputTool;
387        let result: ToolResult = "Process 12345\nStatus: Running\nNo new output".into();
388
389        let formatted = tool.format_output_markdown(&result);
390
391        assert!(formatted.contains("*No output*"));
392    }
393
394    #[test]
395    fn test_format_output_markdown_status_emojis() {
396        let tool = ReadProcessOutputTool;
397
398        // Running = green circle
399        let running: ToolResult = "Process 1\nStatus: Running\nNo new output".into();
400        assert!(tool.format_output_markdown(&running).contains("🟢"));
401
402        // Completed = blue circle
403        let completed: ToolResult = "Process 1\nStatus: Completed\nNo new output".into();
404        assert!(tool.format_output_markdown(&completed).contains("🔵"));
405
406        // Waiting = yellow circle
407        let waiting: ToolResult = "Process 1\nStatus: WaitingForInput\nNo new output".into();
408        assert!(tool.format_output_markdown(&waiting).contains("🟡"));
409    }
410
411    // ==================== default value tests ====================
412
413    #[test]
414    fn test_default_timeout() {
415        assert_eq!(default_timeout(), 5000);
416    }
417
418    // ==================== Tool metadata tests ====================
419
420    #[test]
421    fn test_tool_name() {
422        let tool = ReadProcessOutputTool;
423        assert_eq!(tool.name(), "read_process_output");
424    }
425
426    #[test]
427    fn test_tool_description() {
428        let tool = ReadProcessOutputTool;
429        assert!(!tool.description().is_empty());
430        assert!(tool.description().contains("output"));
431    }
432}