Skip to main content

perl_subprocess_runtime/
lib.rs

1//! Subprocess execution abstraction for provider purity
2//!
3//! This crate provides a trait-based abstraction for subprocess execution,
4//! enabling testing with mock implementations and WASM compatibility.
5
6#![deny(unsafe_code)]
7#![cfg_attr(test, allow(clippy::panic, clippy::unwrap_used, clippy::expect_used))]
8#![warn(rust_2018_idioms)]
9#![warn(missing_docs)]
10#![warn(clippy::all)]
11
12use std::fmt;
13#[cfg(all(not(target_arch = "wasm32"), windows))]
14use std::path::Path;
15
16/// Output from a subprocess execution
17#[derive(Debug, Clone)]
18pub struct SubprocessOutput {
19    /// Standard output bytes
20    pub stdout: Vec<u8>,
21    /// Standard error bytes
22    pub stderr: Vec<u8>,
23    /// Exit status code (0 typically indicates success)
24    pub status_code: i32,
25}
26
27impl SubprocessOutput {
28    /// Returns true if the subprocess exited successfully (status code 0)
29    pub fn success(&self) -> bool {
30        self.status_code == 0
31    }
32
33    /// Returns stdout as a UTF-8 string, lossy converting invalid bytes
34    pub fn stdout_lossy(&self) -> String {
35        String::from_utf8_lossy(&self.stdout).into_owned()
36    }
37
38    /// Returns stderr as a UTF-8 string, lossy converting invalid bytes
39    pub fn stderr_lossy(&self) -> String {
40        String::from_utf8_lossy(&self.stderr).into_owned()
41    }
42}
43
44/// Error type for subprocess execution failures
45#[derive(Debug, Clone)]
46pub struct SubprocessError {
47    /// Human-readable error message
48    pub message: String,
49}
50
51impl SubprocessError {
52    /// Create a new subprocess error with the given message
53    pub fn new(message: impl Into<String>) -> Self {
54        Self { message: message.into() }
55    }
56}
57
58impl fmt::Display for SubprocessError {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        write!(f, "{}", self.message)
61    }
62}
63
64impl std::error::Error for SubprocessError {}
65
66/// Abstraction trait for subprocess execution.
67pub trait SubprocessRuntime: Send + Sync {
68    /// Execute a command with the given arguments and optional stdin.
69    fn run_command(
70        &self,
71        program: &str,
72        args: &[&str],
73        stdin: Option<&[u8]>,
74    ) -> Result<SubprocessOutput, SubprocessError>;
75}
76
77/// Default implementation using `std::process::Command`.
78#[cfg(not(target_arch = "wasm32"))]
79pub struct OsSubprocessRuntime {
80    timeout_secs: Option<u64>,
81}
82
83#[cfg(not(target_arch = "wasm32"))]
84impl OsSubprocessRuntime {
85    /// Create a new OS subprocess runtime with no timeout.
86    pub fn new() -> Self {
87        Self { timeout_secs: None }
88    }
89
90    /// Create a new OS subprocess runtime with the given wall-clock timeout.
91    ///
92    /// If the subprocess does not complete within `timeout_secs` seconds the
93    /// call returns a `SubprocessError` with a "timed out" message.  The
94    /// spawned process is left for the OS to reap — it is not explicitly
95    /// killed.
96    ///
97    /// # Stdin size caveat
98    ///
99    /// Stdin data is written synchronously before the timeout poll loop begins.
100    /// If the subprocess hangs before consuming stdin and the data exceeds the
101    /// OS pipe buffer (~64 KiB on Linux), `run_command` will block in the write
102    /// phase and the timeout will not fire.  For typical Perl source files this
103    /// is not a concern.
104    ///
105    /// # Panics
106    ///
107    /// Panics if `timeout_secs` is zero (a zero-second timeout would time out
108    /// every command immediately and is almost certainly a caller bug).
109    pub fn with_timeout(timeout_secs: u64) -> Self {
110        assert!(timeout_secs > 0, "timeout_secs must be greater than zero");
111        Self { timeout_secs: Some(timeout_secs) }
112    }
113}
114
115#[cfg(not(target_arch = "wasm32"))]
116impl Default for OsSubprocessRuntime {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122#[cfg(not(target_arch = "wasm32"))]
123impl SubprocessRuntime for OsSubprocessRuntime {
124    fn run_command(
125        &self,
126        program: &str,
127        args: &[&str],
128        stdin: Option<&[u8]>,
129    ) -> Result<SubprocessOutput, SubprocessError> {
130        use std::io::Write;
131        use std::process::{Command, Stdio};
132
133        let (resolved_program, resolved_args) = resolve_command_invocation(program, args);
134        let mut cmd = Command::new(&resolved_program);
135        cmd.args(resolved_args.iter().map(String::as_str));
136
137        if stdin.is_some() {
138            cmd.stdin(Stdio::piped());
139        }
140
141        cmd.stdout(Stdio::piped());
142        cmd.stderr(Stdio::piped());
143
144        let mut child = cmd
145            .spawn()
146            .map_err(|e| SubprocessError::new(format!("Failed to start {}: {}", program, e)))?;
147
148        if let Some(input) = stdin
149            && let Some(mut child_stdin) = child.stdin.take()
150        {
151            child_stdin.write_all(input).map_err(|e| {
152                SubprocessError::new(format!("Failed to write to {} stdin: {}", program, e))
153            })?;
154        }
155
156        match self.timeout_secs {
157            None => {
158                let output = child.wait_with_output().map_err(|e| {
159                    SubprocessError::new(format!("Failed to wait for {}: {}", program, e))
160                })?;
161                Ok(SubprocessOutput {
162                    stdout: output.stdout,
163                    stderr: output.stderr,
164                    status_code: output.status.code().unwrap_or(-1),
165                })
166            }
167            Some(secs) => {
168                use std::thread;
169                use std::time::{Duration, Instant};
170
171                let deadline = Instant::now() + Duration::from_secs(secs);
172                let program_name = program.to_string();
173                let handle = thread::spawn(move || child.wait_with_output());
174
175                loop {
176                    // Check completion before the deadline so a process that
177                    // finishes exactly at the deadline boundary is never
178                    // reported as timed out.
179                    if handle.is_finished() {
180                        let output = handle
181                            .join()
182                            .map_err(|_| SubprocessError::new("subprocess thread panicked"))?
183                            .map_err(|e| {
184                                SubprocessError::new(format!(
185                                    "Failed to wait for {}: {}",
186                                    program_name, e
187                                ))
188                            })?;
189                        return Ok(SubprocessOutput {
190                            stdout: output.stdout,
191                            stderr: output.stderr,
192                            status_code: output.status.code().unwrap_or(-1),
193                        });
194                    }
195
196                    if Instant::now() >= deadline {
197                        // Background thread may still be running; deliberately do not
198                        // join — the spawned process will be reaped by the OS.
199                        return Err(SubprocessError::new(format!(
200                            "subprocess timed out after {} seconds",
201                            secs
202                        )));
203                    }
204
205                    thread::sleep(Duration::from_millis(50));
206                }
207            }
208        }
209    }
210}
211
212#[cfg(not(target_arch = "wasm32"))]
213fn resolve_command_invocation(program: &str, args: &[&str]) -> (String, Vec<String>) {
214    #[cfg(windows)]
215    {
216        let resolved_program =
217            resolve_windows_program(program).unwrap_or_else(|| program.to_string());
218
219        if windows_requires_cmd_shell(&resolved_program) {
220            let mut shell_args = vec!["/C".to_string(), resolved_program];
221            shell_args.extend(args.iter().map(|arg| (*arg).to_string()));
222            return ("cmd.exe".to_string(), shell_args);
223        }
224
225        (resolved_program, args.iter().map(|arg| (*arg).to_string()).collect())
226    }
227
228    #[cfg(not(windows))]
229    {
230        (program.to_string(), args.iter().map(|arg| (*arg).to_string()).collect())
231    }
232}
233
234#[cfg(all(not(target_arch = "wasm32"), windows))]
235fn resolve_windows_program(program: &str) -> Option<String> {
236    let program_path = Path::new(program);
237    let has_separator = program.contains('\\') || program.contains('/');
238    let has_extension = program_path.extension().is_some();
239
240    if has_separator || has_extension {
241        return Some(program.to_string());
242    }
243
244    let output = std::process::Command::new("where")
245        .arg(program)
246        .stdout(std::process::Stdio::piped())
247        .stderr(std::process::Stdio::null())
248        .output()
249        .ok()?;
250
251    if !output.status.success() {
252        return None;
253    }
254
255    String::from_utf8(output.stdout)
256        .ok()?
257        .lines()
258        .map(str::trim)
259        .filter(|line| !line.is_empty())
260        .max_by_key(|candidate| windows_program_priority(candidate))
261        .map(String::from)
262}
263
264#[cfg(all(not(target_arch = "wasm32"), windows))]
265fn windows_program_priority(candidate: &str) -> u8 {
266    match Path::new(candidate)
267        .extension()
268        .and_then(|ext| ext.to_str())
269        .map(|ext| ext.to_ascii_lowercase())
270    {
271        Some(ext) if ext == "exe" => 5,
272        Some(ext) if ext == "com" => 4,
273        Some(ext) if ext == "cmd" => 3,
274        Some(ext) if ext == "bat" => 2,
275        Some(_) => 1,
276        None => 0,
277    }
278}
279
280#[cfg(all(not(target_arch = "wasm32"), windows))]
281fn windows_requires_cmd_shell(program: &str) -> bool {
282    Path::new(program)
283        .extension()
284        .and_then(|ext| ext.to_str())
285        .map(|ext| ext.eq_ignore_ascii_case("bat") || ext.eq_ignore_ascii_case("cmd"))
286        .unwrap_or(false)
287}
288
289/// Mock subprocess runtime for testing.
290pub mod mock {
291    use super::*;
292    use std::sync::{Arc, Mutex, MutexGuard};
293
294    fn lock<'a, T>(mutex: &'a Mutex<T>) -> MutexGuard<'a, T> {
295        match mutex.lock() {
296            Ok(guard) => guard,
297            Err(poisoned) => poisoned.into_inner(),
298        }
299    }
300
301    /// A recorded command invocation.
302    #[derive(Debug, Clone)]
303    pub struct CommandInvocation {
304        /// The program that was called.
305        pub program: String,
306        /// The arguments passed.
307        pub args: Vec<String>,
308        /// The stdin data provided.
309        pub stdin: Option<Vec<u8>>,
310    }
311
312    /// Builder for mock responses.
313    #[derive(Debug, Clone)]
314    pub struct MockResponse {
315        /// Stdout to return.
316        pub stdout: Vec<u8>,
317        /// Stderr to return.
318        pub stderr: Vec<u8>,
319        /// Status code to return.
320        pub status_code: i32,
321    }
322
323    impl MockResponse {
324        /// Create a successful mock response with the given stdout.
325        pub fn success(stdout: impl Into<Vec<u8>>) -> Self {
326            Self { stdout: stdout.into(), stderr: Vec::new(), status_code: 0 }
327        }
328
329        /// Create a failed mock response with the given stderr.
330        pub fn failure(stderr: impl Into<Vec<u8>>, status_code: i32) -> Self {
331            Self { stdout: Vec::new(), stderr: stderr.into(), status_code }
332        }
333    }
334
335    /// Mock subprocess runtime for testing.
336    pub struct MockSubprocessRuntime {
337        invocations: Arc<Mutex<Vec<CommandInvocation>>>,
338        responses: Arc<Mutex<Vec<MockResponse>>>,
339        default_response: MockResponse,
340    }
341
342    impl MockSubprocessRuntime {
343        /// Create a new mock runtime with a default successful response.
344        pub fn new() -> Self {
345            Self {
346                invocations: Arc::new(Mutex::new(Vec::new())),
347                responses: Arc::new(Mutex::new(Vec::new())),
348                default_response: MockResponse::success(Vec::new()),
349            }
350        }
351
352        /// Add a response to be returned for the next command.
353        pub fn add_response(&self, response: MockResponse) {
354            lock(&self.responses).push(response);
355        }
356
357        /// Set the default response when no queued responses remain.
358        pub fn set_default_response(&mut self, response: MockResponse) {
359            self.default_response = response;
360        }
361
362        /// Get all recorded invocations.
363        pub fn invocations(&self) -> Vec<CommandInvocation> {
364            lock(&self.invocations).clone()
365        }
366
367        /// Clear recorded invocations.
368        pub fn clear_invocations(&self) {
369            lock(&self.invocations).clear();
370        }
371    }
372
373    impl Default for MockSubprocessRuntime {
374        fn default() -> Self {
375            Self::new()
376        }
377    }
378
379    impl SubprocessRuntime for MockSubprocessRuntime {
380        fn run_command(
381            &self,
382            program: &str,
383            args: &[&str],
384            stdin: Option<&[u8]>,
385        ) -> Result<SubprocessOutput, SubprocessError> {
386            lock(&self.invocations).push(CommandInvocation {
387                program: program.to_string(),
388                args: args.iter().map(|s| s.to_string()).collect(),
389                stdin: stdin.map(|s| s.to_vec()),
390            });
391
392            let response = {
393                let mut responses = lock(&self.responses);
394                if responses.is_empty() {
395                    self.default_response.clone()
396                } else {
397                    responses.remove(0)
398                }
399            };
400
401            Ok(SubprocessOutput {
402                stdout: response.stdout,
403                stderr: response.stderr,
404                status_code: response.status_code,
405            })
406        }
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn test_subprocess_output_success() {
416        let output = SubprocessOutput { stdout: vec![1, 2, 3], stderr: vec![], status_code: 0 };
417        assert!(output.success());
418    }
419
420    #[test]
421    fn test_subprocess_output_failure() {
422        let output = SubprocessOutput { stdout: vec![], stderr: b"error".to_vec(), status_code: 1 };
423        assert!(!output.success());
424        assert_eq!(output.stderr_lossy(), "error");
425    }
426
427    #[test]
428    fn test_subprocess_error_display() {
429        let error = SubprocessError::new("test error");
430        assert_eq!(format!("{}", error), "test error");
431    }
432
433    #[test]
434    fn test_mock_runtime() {
435        use mock::*;
436
437        let runtime = MockSubprocessRuntime::new();
438        runtime.add_response(MockResponse::success(b"formatted code".to_vec()));
439
440        let result = runtime.run_command("perltidy", &["-st"], Some(b"my $x = 1;"));
441
442        assert!(result.is_ok());
443        let output = perl_tdd_support::must(result);
444        assert!(output.success());
445        assert_eq!(output.stdout_lossy(), "formatted code");
446
447        let invocations = runtime.invocations();
448        assert_eq!(invocations.len(), 1);
449        assert_eq!(invocations[0].program, "perltidy");
450        assert_eq!(invocations[0].args, vec!["-st"]);
451        assert_eq!(invocations[0].stdin, Some(b"my $x = 1;".to_vec()));
452    }
453
454    #[cfg(not(target_arch = "wasm32"))]
455    #[test]
456    fn test_os_runtime_echo() {
457        let runtime = OsSubprocessRuntime::new();
458        #[cfg(windows)]
459        let result = runtime.run_command("cmd.exe", &["/C", "echo", "hello"], None);
460        #[cfg(not(windows))]
461        let result = runtime.run_command("echo", &["hello"], None);
462
463        assert!(result.is_ok());
464        let output = perl_tdd_support::must(result);
465        assert!(output.success());
466        assert!(output.stdout_lossy().trim() == "hello");
467    }
468
469    #[cfg(not(target_arch = "wasm32"))]
470    #[test]
471    fn test_os_runtime_nonexistent() {
472        let runtime = OsSubprocessRuntime::new();
473
474        let result = runtime.run_command("nonexistent_program_xyz", &[], None);
475
476        assert!(result.is_err());
477    }
478
479    #[cfg(windows)]
480    #[test]
481    fn test_resolve_command_invocation_uses_cmd_for_batch_wrappers() {
482        let (program, args) =
483            resolve_command_invocation(r"C:\Strawberry\perl\bin\perltidy.bat", &["-st", "-se"]);
484
485        assert_eq!(program, "cmd.exe");
486        assert_eq!(
487            args,
488            vec![
489                "/C".to_string(),
490                r"C:\Strawberry\perl\bin\perltidy.bat".to_string(),
491                "-st".to_string(),
492                "-se".to_string(),
493            ]
494        );
495    }
496
497    #[cfg(windows)]
498    #[test]
499    fn test_resolve_command_invocation_preserves_executable_paths() {
500        let (program, args) =
501            resolve_command_invocation(r"C:\tools\perlcritic.exe", &["--version"]);
502
503        assert_eq!(program, r"C:\tools\perlcritic.exe");
504        assert_eq!(args, vec!["--version".to_string()]);
505    }
506
507    #[cfg(windows)]
508    #[test]
509    fn test_windows_program_priority_prefers_real_wrappers_over_extensionless_shims() {
510        let mut candidates = vec![
511            r"C:\Strawberry\perl\bin\perltidy".to_string(),
512            r"C:\Strawberry\perl\bin\perltidy.bat".to_string(),
513            r"C:\tools\perltidy.exe".to_string(),
514        ];
515        candidates.sort_by_key(|candidate| windows_program_priority(candidate));
516
517        assert_eq!(candidates.last().map(String::as_str), Some(r"C:\tools\perltidy.exe"));
518        assert!(
519            windows_program_priority(r"C:\Strawberry\perl\bin\perltidy.bat")
520                > windows_program_priority(r"C:\Strawberry\perl\bin\perltidy")
521        );
522    }
523}