Skip to main content

copilot_sdk/
process.rs

1// Copyright (c) 2026 Elias Bachaalany
2// SPDX-License-Identifier: MIT
3
4//! Process management for the Copilot SDK.
5//!
6//! Provides async subprocess spawning and management for the Copilot CLI.
7
8use crate::error::{CopilotError, Result};
9use crate::transport::StdioTransport;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::process::Stdio;
13use tokio::process::{Child, Command};
14
15// =============================================================================
16// Process Options
17// =============================================================================
18
19/// Options for spawning a subprocess.
20#[derive(Debug, Clone)]
21pub struct ProcessOptions {
22    /// Working directory for the subprocess (None = inherit from parent).
23    pub working_directory: Option<PathBuf>,
24
25    /// Environment variables to set.
26    pub environment: HashMap<String, String>,
27
28    /// Whether to inherit the parent's environment variables.
29    pub inherit_environment: bool,
30
31    /// Whether to redirect stdin (pipe to subprocess).
32    pub redirect_stdin: bool,
33
34    /// Whether to redirect stdout (pipe from subprocess).
35    pub redirect_stdout: bool,
36
37    /// Whether to redirect stderr (pipe from subprocess).
38    pub redirect_stderr: bool,
39}
40
41impl Default for ProcessOptions {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl ProcessOptions {
48    /// Create new process options with default values.
49    pub fn new() -> Self {
50        Self {
51            working_directory: None,
52            environment: HashMap::new(),
53            inherit_environment: true,
54            redirect_stdin: true,
55            redirect_stdout: true,
56            redirect_stderr: false,
57        }
58    }
59
60    /// Set working directory.
61    pub fn working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
62        self.working_directory = Some(dir.into());
63        self
64    }
65
66    /// Add environment variable.
67    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
68        self.environment.insert(key.into(), value.into());
69        self
70    }
71
72    /// Set whether to inherit parent environment.
73    pub fn inherit_env(mut self, inherit: bool) -> Self {
74        self.inherit_environment = inherit;
75        self
76    }
77
78    /// Set stdin redirection.
79    pub fn stdin(mut self, redirect: bool) -> Self {
80        self.redirect_stdin = redirect;
81        self
82    }
83
84    /// Set stdout redirection.
85    pub fn stdout(mut self, redirect: bool) -> Self {
86        self.redirect_stdout = redirect;
87        self
88    }
89
90    /// Set stderr redirection.
91    pub fn stderr(mut self, redirect: bool) -> Self {
92        self.redirect_stderr = redirect;
93        self
94    }
95}
96
97// =============================================================================
98// Copilot Process
99// =============================================================================
100
101/// A running Copilot CLI process.
102pub struct CopilotProcess {
103    child: Child,
104    transport: Option<StdioTransport>,
105    stdout: Option<tokio::process::ChildStdout>,
106    stderr: Option<tokio::process::ChildStderr>,
107}
108
109impl CopilotProcess {
110    /// Spawn a new Copilot CLI process.
111    pub fn spawn(
112        executable: impl AsRef<Path>,
113        args: &[&str],
114        options: ProcessOptions,
115    ) -> Result<Self> {
116        let executable = executable.as_ref();
117
118        // Build command
119        let mut cmd = Command::new(executable);
120        cmd.args(args);
121
122        // Set working directory
123        if let Some(dir) = &options.working_directory {
124            cmd.current_dir(dir);
125        }
126
127        // Set environment
128        if !options.inherit_environment {
129            cmd.env_clear();
130        }
131        for (key, value) in &options.environment {
132            cmd.env(key, value);
133        }
134
135        // Configure stdio
136        cmd.stdin(if options.redirect_stdin {
137            Stdio::piped()
138        } else {
139            Stdio::null()
140        });
141        cmd.stdout(if options.redirect_stdout {
142            Stdio::piped()
143        } else {
144            Stdio::null()
145        });
146        cmd.stderr(if options.redirect_stderr {
147            Stdio::piped()
148        } else {
149            Stdio::null()
150        });
151
152        // Spawn the process
153        let mut child = cmd.spawn().map_err(CopilotError::ProcessStart)?;
154
155        // Create transport from stdio handles
156        let transport = if options.redirect_stdin && options.redirect_stdout {
157            let stdin = child
158                .stdin
159                .take()
160                .ok_or_else(|| CopilotError::InvalidConfig("Failed to capture stdin".into()))?;
161            let stdout = child
162                .stdout
163                .take()
164                .ok_or_else(|| CopilotError::InvalidConfig("Failed to capture stdout".into()))?;
165            Some(StdioTransport::new(stdin, stdout))
166        } else {
167            None
168        };
169
170        // Capture stdout if redirected but not used for stdio transport.
171        let stdout = if transport.is_none() && options.redirect_stdout {
172            child.stdout.take()
173        } else {
174            None
175        };
176
177        // Capture stderr if redirected
178        let stderr = if options.redirect_stderr {
179            child.stderr.take()
180        } else {
181            None
182        };
183
184        Ok(Self {
185            child,
186            transport,
187            stdout,
188            stderr,
189        })
190    }
191
192    /// Spawn the Copilot CLI with default options for stdio mode.
193    pub fn spawn_stdio(cli_path: impl AsRef<Path>) -> Result<Self> {
194        let options = ProcessOptions::new().stdin(true).stdout(true).stderr(false);
195
196        Self::spawn(cli_path, &["--stdio"], options)
197    }
198
199    /// Take the transport (can only be called once).
200    ///
201    /// Returns the stdio transport for communication with the CLI.
202    pub fn take_transport(&mut self) -> Option<StdioTransport> {
203        self.transport.take()
204    }
205
206    /// Take stdout (can only be called once).
207    pub fn take_stdout(&mut self) -> Option<tokio::process::ChildStdout> {
208        self.stdout.take()
209    }
210
211    /// Get the process ID.
212    pub fn id(&self) -> Option<u32> {
213        self.child.id()
214    }
215
216    /// Check if the process is still running.
217    pub async fn is_running(&mut self) -> bool {
218        self.child.try_wait().ok().flatten().is_none()
219    }
220
221    /// Try to get the exit status without blocking.
222    pub async fn try_wait(&mut self) -> Result<Option<i32>> {
223        match self.child.try_wait() {
224            Ok(Some(status)) => Ok(Some(status.code().unwrap_or(-1))),
225            Ok(None) => Ok(None),
226            Err(e) => Err(CopilotError::Transport(e)),
227        }
228    }
229
230    /// Wait for the process to exit.
231    pub async fn wait(&mut self) -> Result<i32> {
232        let status = self.child.wait().await.map_err(CopilotError::Transport)?;
233        Ok(status.code().unwrap_or(-1))
234    }
235
236    /// Request termination of the process.
237    ///
238    /// On Unix, this sends SIGTERM. On Windows, this kills the process.
239    pub fn terminate(&mut self) -> Result<()> {
240        // Use kill for cross-platform simplicity
241        // A more sophisticated implementation could use SIGTERM on Unix
242        self.kill()
243    }
244
245    /// Forcefully kill the process.
246    pub fn kill(&mut self) -> Result<()> {
247        self.child.start_kill().map_err(CopilotError::Transport)
248    }
249
250    /// Take stderr (can only be called once).
251    pub fn take_stderr(&mut self) -> Option<tokio::process::ChildStderr> {
252        self.stderr.take()
253    }
254}
255
256// =============================================================================
257// Utility Functions
258// =============================================================================
259
260/// Find an executable in the system PATH.
261///
262/// Returns the full path to the executable if found.
263pub fn find_executable(name: &str) -> Option<PathBuf> {
264    which::which(name).ok()
265}
266
267/// Check if a path looks like a Node.js script.
268pub fn is_node_script(path: &Path) -> bool {
269    path.extension()
270        .is_some_and(|ext| ext == "js" || ext == "mjs")
271}
272
273/// Get the system's Node.js executable path.
274pub fn find_node() -> Option<PathBuf> {
275    find_executable("node")
276}
277
278/// Find the Copilot CLI executable.
279///
280/// Searches for the Copilot CLI in common locations and the system PATH.
281pub fn find_copilot_cli() -> Option<PathBuf> {
282    // First, allow an explicit override to match the upstream SDKs.
283    if let Ok(cli_path) = std::env::var("COPILOT_CLI_PATH") {
284        let cli_path = cli_path.trim();
285        if !cli_path.is_empty() {
286            let path = PathBuf::from(cli_path);
287            if path.exists() {
288                return Some(path);
289            }
290        }
291    }
292
293    // First, try the system PATH
294    if let Some(path) = find_executable("copilot") {
295        return Some(path);
296    }
297
298    // On Windows, also try "copilot.cmd" and "copilot.exe"
299    #[cfg(windows)]
300    {
301        if let Some(path) = find_executable("copilot.cmd") {
302            return Some(path);
303        }
304        if let Some(path) = find_executable("copilot.exe") {
305            return Some(path);
306        }
307    }
308
309    None
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_process_options_builder() {
318        let options = ProcessOptions::new()
319            .working_dir("/tmp")
320            .env("FOO", "bar")
321            .inherit_env(false)
322            .stdin(true)
323            .stdout(true)
324            .stderr(true);
325
326        assert_eq!(options.working_directory, Some(PathBuf::from("/tmp")));
327        assert_eq!(options.environment.get("FOO"), Some(&"bar".to_string()));
328        assert!(!options.inherit_environment);
329        assert!(options.redirect_stdin);
330        assert!(options.redirect_stdout);
331        assert!(options.redirect_stderr);
332    }
333
334    #[test]
335    fn test_process_options_default() {
336        let options = ProcessOptions::default();
337
338        assert!(options.working_directory.is_none());
339        assert!(options.environment.is_empty());
340        assert!(options.inherit_environment);
341        assert!(options.redirect_stdin);
342        assert!(options.redirect_stdout);
343        assert!(!options.redirect_stderr);
344    }
345
346    #[test]
347    fn test_is_node_script() {
348        assert!(is_node_script(Path::new("script.js")));
349        assert!(is_node_script(Path::new("script.mjs")));
350        assert!(is_node_script(Path::new("/path/to/script.js")));
351        assert!(!is_node_script(Path::new("script.ts")));
352        assert!(!is_node_script(Path::new("script")));
353        assert!(!is_node_script(Path::new("script.py")));
354    }
355
356    #[test]
357    fn test_find_node() {
358        // This test just verifies the function doesn't panic
359        // Whether it finds node depends on the system
360        let _ = find_node();
361    }
362
363    #[test]
364    fn test_find_copilot_cli() {
365        // This test just verifies the function doesn't panic
366        // Whether it finds copilot depends on the system
367        let _ = find_copilot_cli();
368    }
369}