Skip to main content

fresh_plugin_runtime/
process.rs

1//! Plugin Process Spawning: Async process execution for plugins
2//!
3//! This module enables plugins to spawn external processes asynchronously,
4//! capturing stdout/stderr and notifying via callbacks when complete.
5
6use fresh_core::api::PluginAsyncMessage as AsyncMessage;
7use std::process::Stdio;
8use std::sync::mpsc;
9use tokio::io::{AsyncBufReadExt, BufReader};
10use tokio::process::Command;
11
12/// Consume and discard a `Result` from a fire-and-forget channel send.
13///
14/// Use when the receiver may have been dropped (e.g. during shutdown) and
15/// failure is expected and non-actionable.
16fn fire_and_forget<E: std::fmt::Debug>(result: Result<(), E>) {
17    if let Err(e) = result {
18        tracing::trace!(error = ?e, "fire-and-forget send failed");
19    }
20}
21
22/// Spawn an external process for a plugin
23///
24/// This function:
25/// 1. Spawns the process asynchronously
26/// 2. Captures all stdout and stderr
27/// 3. Waits for process completion
28/// 4. Sends results back via AsyncBridge with process_id for callback matching
29///
30/// # Arguments
31/// * `process_id` - Unique ID to match with callback
32/// * `command` - Command to execute (e.g., "git")
33/// * `args` - Command arguments
34/// * `cwd` - Optional working directory
35/// * `sender` - Channel to send results back to main loop
36pub async fn spawn_plugin_process(
37    process_id: u64,
38    command: String,
39    args: Vec<String>,
40    cwd: Option<String>,
41    sender: mpsc::Sender<AsyncMessage>,
42) {
43    // Build the command
44    let mut cmd = Command::new(&command);
45    cmd.args(&args);
46    cmd.stdout(Stdio::piped());
47    cmd.stderr(Stdio::piped());
48
49    // Set working directory if provided
50    if let Some(ref dir) = cwd {
51        cmd.current_dir(dir);
52    }
53
54    // Spawn the process
55    let mut child = match cmd.spawn() {
56        Ok(child) => child,
57        Err(e) => {
58            // Failed to spawn - send error result
59            fire_and_forget(sender.send(AsyncMessage::ProcessOutput {
60                process_id,
61                stdout: String::new(),
62                stderr: format!("Failed to spawn process: {}", e),
63                exit_code: -1,
64            }));
65            return;
66        }
67    };
68
69    // Capture stdout and stderr
70    let stdout_handle = child.stdout.take();
71    let stderr_handle = child.stderr.take();
72
73    // Read stdout
74    let stdout_future = async {
75        if let Some(stdout) = stdout_handle {
76            let reader = BufReader::new(stdout);
77            let mut lines = reader.lines();
78            let mut output = String::new();
79
80            while let Ok(Some(line)) = lines.next_line().await {
81                output.push_str(&line);
82                output.push('\n');
83            }
84            output
85        } else {
86            String::new()
87        }
88    };
89
90    // Read stderr
91    let stderr_future = async {
92        if let Some(stderr) = stderr_handle {
93            let reader = BufReader::new(stderr);
94            let mut lines = reader.lines();
95            let mut output = String::new();
96
97            while let Ok(Some(line)) = lines.next_line().await {
98                output.push_str(&line);
99                output.push('\n');
100            }
101            output
102        } else {
103            String::new()
104        }
105    };
106
107    // Wait for both outputs concurrently
108    let (stdout, stderr) = tokio::join!(stdout_future, stderr_future);
109
110    // Wait for process to complete
111    let exit_code = match child.wait().await {
112        Ok(status) => status.code().unwrap_or(-1),
113        Err(_) => -1,
114    };
115
116    // Send results back to main loop
117    fire_and_forget(sender.send(AsyncMessage::ProcessOutput {
118        process_id,
119        stdout,
120        stderr,
121        exit_code,
122    }));
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[tokio::test]
130    async fn test_spawn_simple_command() {
131        let (sender, receiver) = mpsc::channel();
132
133        spawn_plugin_process(
134            1,
135            "echo".to_string(),
136            vec!["hello".to_string()],
137            None,
138            sender,
139        )
140        .await;
141
142        let msg = receiver.recv().unwrap();
143        match msg {
144            AsyncMessage::ProcessOutput {
145                process_id,
146                stdout,
147                stderr,
148                exit_code,
149            } => {
150                assert_eq!(process_id, 1);
151                assert!(stdout.contains("hello"));
152                assert_eq!(stderr, "");
153                assert_eq!(exit_code, 0);
154            }
155            _ => panic!("Expected PluginProcessOutput"),
156        }
157    }
158
159    #[tokio::test]
160    async fn test_spawn_with_args() {
161        let (sender, receiver) = mpsc::channel();
162
163        spawn_plugin_process(
164            2,
165            "printf".to_string(),
166            vec![
167                "%s %s".to_string(),
168                "hello".to_string(),
169                "world".to_string(),
170            ],
171            None,
172            sender,
173        )
174        .await;
175
176        let msg = receiver.recv().unwrap();
177        match msg {
178            AsyncMessage::ProcessOutput {
179                process_id,
180                stdout,
181                exit_code,
182                ..
183            } => {
184                assert_eq!(process_id, 2);
185                assert!(stdout.contains("hello world"));
186                assert_eq!(exit_code, 0);
187            }
188            _ => panic!("Expected PluginProcessOutput"),
189        }
190    }
191
192    #[tokio::test]
193    async fn test_spawn_nonexistent_command() {
194        let (sender, receiver) = mpsc::channel();
195
196        spawn_plugin_process(
197            3,
198            "this_command_does_not_exist_12345".to_string(),
199            vec![],
200            None,
201            sender,
202        )
203        .await;
204
205        let msg = receiver.recv().unwrap();
206        match msg {
207            AsyncMessage::ProcessOutput {
208                process_id,
209                stdout,
210                stderr,
211                exit_code,
212            } => {
213                assert_eq!(process_id, 3);
214                assert_eq!(stdout, "");
215                assert!(stderr.contains("Failed to spawn"));
216                assert_eq!(exit_code, -1);
217            }
218            _ => panic!("Expected PluginProcessOutput"),
219        }
220    }
221
222    #[tokio::test]
223    async fn test_spawn_failing_command() {
224        let (sender, receiver) = mpsc::channel();
225
226        // Use a command that will fail
227        spawn_plugin_process(
228            4,
229            "sh".to_string(),
230            vec!["-c".to_string(), "exit 42".to_string()],
231            None,
232            sender,
233        )
234        .await;
235
236        let msg = receiver.recv().unwrap();
237        match msg {
238            AsyncMessage::ProcessOutput {
239                process_id,
240                exit_code,
241                ..
242            } => {
243                assert_eq!(process_id, 4);
244                assert_eq!(exit_code, 42);
245            }
246            _ => panic!("Expected PluginProcessOutput"),
247        }
248    }
249}