Skip to main content

mixtape_tools/process/
start_process.rs

1use crate::prelude::*;
2use crate::process::session_manager::{ProcessState, SessionManager};
3use std::sync::Arc;
4use tokio::sync::Mutex;
5
6lazy_static::lazy_static! {
7    pub(crate) static ref SESSION_MANAGER: Arc<Mutex<SessionManager>> = Arc::new(Mutex::new(SessionManager::new()));
8}
9
10/// Input for starting a process
11#[derive(Debug, Deserialize, JsonSchema)]
12pub struct StartProcessInput {
13    /// Command to execute
14    pub command: String,
15
16    /// Optional timeout in milliseconds (default: no timeout)
17    #[serde(default)]
18    pub timeout_ms: Option<u64>,
19
20    /// Optional shell to use (defaults to 'sh' on Unix, 'cmd' on Windows)
21    #[serde(default)]
22    pub shell: Option<String>,
23}
24
25/// Tool for starting a new process session
26pub struct StartProcessTool;
27
28impl Tool for StartProcessTool {
29    type Input = StartProcessInput;
30
31    fn name(&self) -> &str {
32        "start_process"
33    }
34
35    fn description(&self) -> &str {
36        "Start a new process session. Returns a PID that can be used to interact with the process, read its output, or terminate it."
37    }
38
39    fn format_output_plain(&self, result: &ToolResult) -> String {
40        let text = result.as_text();
41        let (command, pid, status, output_lines) = parse_start_output(&text);
42
43        let mut out = String::new();
44        out.push_str(&"─".repeat(50));
45        out.push_str("\n  PROCESS STARTED\n");
46        out.push_str(&"─".repeat(50));
47        out.push('\n');
48
49        if let Some(cmd) = command {
50            out.push_str(&format!("  Command: {}\n", cmd));
51        }
52        if let Some(p) = pid {
53            out.push_str(&format!("  PID:     {}\n", p));
54        }
55        if let Some(s) = status {
56            out.push_str(&format!("  Status:  {}\n", s));
57        }
58
59        if !output_lines.is_empty() {
60            out.push_str(&"─".repeat(50));
61            out.push('\n');
62            for line in output_lines {
63                out.push_str(&format!("  {}\n", line));
64            }
65        }
66        out
67    }
68
69    fn format_output_ansi(&self, result: &ToolResult) -> String {
70        let text = result.as_text();
71        let (command, pid, status, output_lines) = parse_start_output(&text);
72
73        let mut out = String::new();
74        out.push_str(&format!("\x1b[2m{}\x1b[0m\n  \x1b[32m●\x1b[0m \x1b[1mProcess Started\x1b[0m\n\x1b[2m{}\x1b[0m\n", "─".repeat(50), "─".repeat(50)));
75
76        if let Some(cmd) = command {
77            out.push_str(&format!(
78                "  \x1b[2mCommand\x1b[0m  \x1b[36m{}\x1b[0m\n",
79                cmd
80            ));
81        }
82        if let Some(p) = pid {
83            out.push_str(&format!("  \x1b[2mPID\x1b[0m      \x1b[33m{}\x1b[0m\n", p));
84        }
85        if let Some(s) = status {
86            let status_color = if s.contains("Running") {
87                "\x1b[32m"
88            } else if s.contains("Completed") {
89                "\x1b[34m"
90            } else {
91                "\x1b[33m"
92            };
93            out.push_str(&format!(
94                "  \x1b[2mStatus\x1b[0m   {}{}\x1b[0m\n",
95                status_color, s
96            ));
97        }
98
99        if !output_lines.is_empty() {
100            out.push_str(&format!("\x1b[2m{}\x1b[0m\n", "─".repeat(50)));
101            for line in output_lines {
102                out.push_str(&format!("  \x1b[2m│\x1b[0m {}\n", line));
103            }
104        }
105        out
106    }
107
108    fn format_output_markdown(&self, result: &ToolResult) -> String {
109        let text = result.as_text();
110        let (command, pid, status, output_lines) = parse_start_output(&text);
111
112        let mut out = String::from("### 🚀 Process Started\n\n");
113        if let Some(cmd) = command {
114            out.push_str(&format!("- **Command**: `{}`\n", cmd));
115        }
116        if let Some(p) = pid {
117            out.push_str(&format!("- **PID**: `{}`\n", p));
118        }
119        if let Some(s) = status {
120            out.push_str(&format!("- **Status**: {}\n", s));
121        }
122
123        if !output_lines.is_empty() {
124            out.push_str("\n**Initial Output:**\n```\n");
125            for line in output_lines {
126                out.push_str(line);
127                out.push('\n');
128            }
129            out.push_str("```\n");
130        }
131        out
132    }
133
134    async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
135        let manager = SESSION_MANAGER.lock().await;
136        let pid = manager
137            .create_session(input.command.clone(), input.shell, input.timeout_ms)
138            .await?;
139
140        // Give the process a moment to start
141        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
142
143        // Read initial output
144        let initial_output = manager.read_output(pid, false).await.unwrap_or_default();
145        let status = manager
146            .check_status(pid)
147            .await
148            .unwrap_or(ProcessState::Running);
149
150        let mut content = format!(
151            "Started process: {}\nPID: {}\nStatus: {:?}\n",
152            input.command, pid, status
153        );
154
155        if !initial_output.is_empty() {
156            content.push_str("\nInitial output:\n");
157            for line in initial_output.iter().take(20) {
158                content.push_str(&format!("{}\n", line));
159            }
160            if initial_output.len() > 20 {
161                content.push_str(&format!(
162                    "... and {} more lines\n",
163                    initial_output.len() - 20
164                ));
165            }
166        }
167
168        Ok(content.into())
169    }
170}
171
172/// Parse start_process output into components
173fn parse_start_output(output: &str) -> (Option<&str>, Option<&str>, Option<&str>, Vec<&str>) {
174    let mut command = None;
175    let mut pid = None;
176    let mut status = None;
177    let mut output_lines = Vec::new();
178    let mut in_output = false;
179
180    for line in output.lines() {
181        if line.starts_with("Started process:") {
182            command = Some(line.trim_start_matches("Started process:").trim());
183        } else if line.starts_with("PID:") {
184            pid = Some(line.trim_start_matches("PID:").trim());
185        } else if line.starts_with("Status:") {
186            status = Some(line.trim_start_matches("Status:").trim());
187        } else if line.starts_with("Initial output:") {
188            in_output = true;
189        } else if in_output && !line.starts_with("...") {
190            output_lines.push(line);
191        }
192    }
193
194    (command, pid, status, output_lines)
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use mixtape_core::ToolResult;
201
202    #[tokio::test]
203    async fn test_start_process_simple_command() {
204        let tool = StartProcessTool;
205
206        // Use 'echo' which works cross-platform
207        let input = StartProcessInput {
208            command: "echo 'Hello from process'".to_string(),
209            timeout_ms: Some(5000),
210            shell: None,
211        };
212
213        let result = tool.execute(input).await;
214        assert!(result.is_ok());
215
216        let output = result.unwrap().as_text();
217        assert!(output.contains("Started process"));
218        assert!(output.contains("PID:"));
219    }
220
221    #[tokio::test]
222    async fn test_start_process_with_timeout() {
223        let tool = StartProcessTool;
224
225        let input = StartProcessInput {
226            command: "echo 'test'".to_string(),
227            timeout_ms: Some(1000),
228            shell: None,
229        };
230
231        let result = tool.execute(input).await;
232        assert!(result.is_ok());
233    }
234
235    #[tokio::test]
236    async fn test_start_process_empty_command() {
237        let tool = StartProcessTool;
238
239        let input = StartProcessInput {
240            command: String::new(),
241            timeout_ms: Some(5000),
242            shell: None,
243        };
244
245        let result = tool.execute(input).await;
246        // Should handle empty command gracefully
247        assert!(result.is_ok() || result.is_err());
248    }
249
250    // ==================== parse_start_output tests ====================
251
252    #[test]
253    fn test_parse_start_output_complete() {
254        let output = "Started process: echo hello\nPID: 12345\nStatus: Running\nInitial output:\nHello World\nLine 2";
255        let (command, pid, status, lines) = parse_start_output(output);
256
257        assert_eq!(command, Some("echo hello"));
258        assert_eq!(pid, Some("12345"));
259        assert_eq!(status, Some("Running"));
260        assert_eq!(lines, vec!["Hello World", "Line 2"]);
261    }
262
263    #[test]
264    fn test_parse_start_output_no_output() {
265        let output = "Started process: sleep 10\nPID: 12345\nStatus: Running";
266        let (command, pid, status, lines) = parse_start_output(output);
267
268        assert_eq!(command, Some("sleep 10"));
269        assert_eq!(pid, Some("12345"));
270        assert_eq!(status, Some("Running"));
271        assert!(lines.is_empty());
272    }
273
274    #[test]
275    fn test_parse_start_output_empty() {
276        let output = "";
277        let (command, pid, status, lines) = parse_start_output(output);
278
279        assert_eq!(command, None);
280        assert_eq!(pid, None);
281        assert_eq!(status, None);
282        assert!(lines.is_empty());
283    }
284
285    #[test]
286    fn test_parse_start_output_partial() {
287        let output = "PID: 99999";
288        let (command, pid, status, lines) = parse_start_output(output);
289
290        assert_eq!(command, None);
291        assert_eq!(pid, Some("99999"));
292        assert_eq!(status, None);
293        assert!(lines.is_empty());
294    }
295
296    #[test]
297    fn test_parse_start_output_with_more_lines_indicator() {
298        let output = "Started process: cmd\nPID: 1\nStatus: Running\nInitial output:\nline1\n... and 5 more lines";
299        let (_, _, _, lines) = parse_start_output(output);
300
301        // The "... and X more lines" should be filtered out
302        assert_eq!(lines, vec!["line1"]);
303    }
304
305    // ==================== format_output tests ====================
306
307    #[test]
308    fn test_format_output_plain_basic() {
309        let tool = StartProcessTool;
310        let result: ToolResult = "Started process: echo test\nPID: 12345\nStatus: Running".into();
311
312        let formatted = tool.format_output_plain(&result);
313
314        assert!(formatted.contains("PROCESS STARTED"));
315        assert!(formatted.contains("Command:"));
316        assert!(formatted.contains("PID:"));
317        assert!(formatted.contains("Status:"));
318    }
319
320    #[test]
321    fn test_format_output_plain_with_output() {
322        let tool = StartProcessTool;
323        let result: ToolResult = "Started process: echo test\nPID: 12345\nStatus: Completed { exit_code: Some(0) }\nInitial output:\nHello".into();
324
325        let formatted = tool.format_output_plain(&result);
326
327        assert!(formatted.contains("Hello"));
328    }
329
330    #[test]
331    fn test_format_output_ansi_colors() {
332        let tool = StartProcessTool;
333        let result: ToolResult = "Started process: echo test\nPID: 12345\nStatus: Running".into();
334
335        let formatted = tool.format_output_ansi(&result);
336
337        // Should contain ANSI escape codes
338        assert!(formatted.contains("\x1b["));
339        assert!(formatted.contains("Process Started"));
340    }
341
342    #[test]
343    fn test_format_output_ansi_status_colors() {
344        let tool = StartProcessTool;
345
346        // Running status should be green
347        let running: ToolResult = "Started process: test\nPID: 1\nStatus: Running".into();
348        let formatted = tool.format_output_ansi(&running);
349        assert!(formatted.contains("\x1b[32m")); // green
350
351        // Completed status should be blue
352        let completed: ToolResult = "Started process: test\nPID: 1\nStatus: Completed".into();
353        let formatted = tool.format_output_ansi(&completed);
354        assert!(formatted.contains("\x1b[34m")); // blue
355    }
356
357    #[test]
358    fn test_format_output_markdown() {
359        let tool = StartProcessTool;
360        let result: ToolResult =
361            "Started process: echo test\nPID: 12345\nStatus: Running\nInitial output:\nHello"
362                .into();
363
364        let formatted = tool.format_output_markdown(&result);
365
366        assert!(formatted.contains("### 🚀 Process Started"));
367        assert!(formatted.contains("**Command**: `echo test`"));
368        assert!(formatted.contains("**PID**: `12345`"));
369        assert!(formatted.contains("**Initial Output:**"));
370        assert!(formatted.contains("```"));
371    }
372
373    #[test]
374    fn test_format_output_markdown_no_output() {
375        let tool = StartProcessTool;
376        let result: ToolResult = "Started process: sleep 10\nPID: 12345\nStatus: Running".into();
377
378        let formatted = tool.format_output_markdown(&result);
379
380        // Should not have output section
381        assert!(!formatted.contains("**Initial Output:**"));
382    }
383
384    // ==================== Tool metadata tests ====================
385
386    #[test]
387    fn test_tool_name() {
388        let tool = StartProcessTool;
389        assert_eq!(tool.name(), "start_process");
390    }
391
392    #[test]
393    fn test_tool_description() {
394        let tool = StartProcessTool;
395        assert!(!tool.description().is_empty());
396        assert!(tool.description().contains("process"));
397    }
398}