1#![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#[derive(Debug, Clone)]
18pub struct SubprocessOutput {
19 pub stdout: Vec<u8>,
21 pub stderr: Vec<u8>,
23 pub status_code: i32,
25}
26
27impl SubprocessOutput {
28 pub fn success(&self) -> bool {
30 self.status_code == 0
31 }
32
33 pub fn stdout_lossy(&self) -> String {
35 String::from_utf8_lossy(&self.stdout).into_owned()
36 }
37
38 pub fn stderr_lossy(&self) -> String {
40 String::from_utf8_lossy(&self.stderr).into_owned()
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct SubprocessError {
47 pub message: String,
49}
50
51impl SubprocessError {
52 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
66pub trait SubprocessRuntime: Send + Sync {
68 fn run_command(
70 &self,
71 program: &str,
72 args: &[&str],
73 stdin: Option<&[u8]>,
74 ) -> Result<SubprocessOutput, SubprocessError>;
75}
76
77#[cfg(not(target_arch = "wasm32"))]
79pub struct OsSubprocessRuntime {
80 timeout_secs: Option<u64>,
81}
82
83#[cfg(not(target_arch = "wasm32"))]
84impl OsSubprocessRuntime {
85 pub fn new() -> Self {
87 Self { timeout_secs: None }
88 }
89
90 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 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 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
289pub 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 #[derive(Debug, Clone)]
303 pub struct CommandInvocation {
304 pub program: String,
306 pub args: Vec<String>,
308 pub stdin: Option<Vec<u8>>,
310 }
311
312 #[derive(Debug, Clone)]
314 pub struct MockResponse {
315 pub stdout: Vec<u8>,
317 pub stderr: Vec<u8>,
319 pub status_code: i32,
321 }
322
323 impl MockResponse {
324 pub fn success(stdout: impl Into<Vec<u8>>) -> Self {
326 Self { stdout: stdout.into(), stderr: Vec::new(), status_code: 0 }
327 }
328
329 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 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 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 pub fn add_response(&self, response: MockResponse) {
354 lock(&self.responses).push(response);
355 }
356
357 pub fn set_default_response(&mut self, response: MockResponse) {
359 self.default_response = response;
360 }
361
362 pub fn invocations(&self) -> Vec<CommandInvocation> {
364 lock(&self.invocations).clone()
365 }
366
367 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}