mdbook_validator/
command.rs

1//! Command execution abstraction for testing.
2//!
3//! Provides a trait for running shell commands, enabling mocking in tests
4//! to cover error paths (spawn failure, stdin failure, wait failure).
5
6use anyhow::{Context, Result};
7use std::io::Write;
8use std::process::{Command, Output, Stdio};
9
10/// Trait for running shell commands.
11///
12/// Enables mocking in tests to verify error handling without actual failures.
13/// Uses generics for zero-cost abstraction in production code.
14pub trait CommandRunner: Send + Sync {
15    /// Run a validator script with the given stdin content and environment variables.
16    ///
17    /// # Arguments
18    ///
19    /// * `script_path` - Path to the script to execute (run via `sh`)
20    /// * `stdin_content` - Content to write to the script's stdin
21    /// * `env_vars` - Environment variables to set for the script
22    ///
23    /// # Errors
24    ///
25    /// Returns error if spawning the process, writing stdin, or waiting for output fails.
26    fn run_script(
27        &self,
28        script_path: &str,
29        stdin_content: &str,
30        env_vars: &[(&str, &str)],
31    ) -> Result<Output>;
32}
33
34/// Real implementation using [`std::process::Command`].
35///
36/// This is the default implementation used in production.
37#[derive(Debug, Default, Clone, Copy)]
38pub struct RealCommandRunner;
39
40impl CommandRunner for RealCommandRunner {
41    fn run_script(
42        &self,
43        script_path: &str,
44        stdin_content: &str,
45        env_vars: &[(&str, &str)],
46    ) -> Result<Output> {
47        let mut cmd = Command::new("bash");
48        cmd.arg(script_path)
49            .stdin(Stdio::piped())
50            .stdout(Stdio::piped())
51            .stderr(Stdio::piped());
52
53        // Set environment variables
54        for (key, value) in env_vars {
55            cmd.env(*key, *value);
56        }
57
58        let mut child = cmd
59            .spawn()
60            .with_context(|| format!("Failed to spawn validator: {script_path}"))?;
61
62        // Write content to stdin
63        // Note: EPIPE (broken pipe) can occur if the process exits before we finish writing.
64        // This is expected when the script doesn't exist or exits immediately.
65        // We ignore EPIPE and continue to get the exit code.
66        if let Some(mut stdin) = child.stdin.take() {
67            if let Err(e) = stdin.write_all(stdin_content.as_bytes()) {
68                // Only fail for errors other than broken pipe
69                if e.kind() != std::io::ErrorKind::BrokenPipe {
70                    return Err(e).context("Failed to write to validator stdin");
71                }
72                // EPIPE is fine - process exited early, we'll get exit code below
73            }
74        }
75
76        child
77            .wait_with_output()
78            .context("Failed to wait for validator")
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    #![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)]
85
86    use super::*;
87
88    #[test]
89    fn test_real_command_runner_default() {
90        let runner = RealCommandRunner;
91        // Just verify it can be created
92        let _ = runner;
93    }
94
95    #[test]
96    fn test_real_command_runner_clone() {
97        let runner = RealCommandRunner;
98        let cloned = runner;
99        let _ = cloned;
100    }
101
102    #[test]
103    fn test_run_script_success() {
104        let runner = RealCommandRunner;
105        // Create a simple script that exits successfully
106        let result = runner.run_script("tests/fixtures/echo_validator.sh", "", &[]);
107        assert!(result.is_ok());
108    }
109
110    #[test]
111    fn test_run_script_with_stdin() {
112        let runner = RealCommandRunner;
113        // Use a real script that reads stdin
114        let result = runner.run_script("tests/fixtures/echo_validator.sh", "test input", &[]);
115        assert!(result.is_ok());
116    }
117
118    #[test]
119    fn test_run_script_with_env_vars() {
120        let runner = RealCommandRunner;
121        // Use echo_validator.sh which echoes VALIDATOR_ASSERTIONS env var
122        let result = runner.run_script(
123            "tests/fixtures/echo_validator.sh",
124            "{}",
125            &[("VALIDATOR_ASSERTIONS", "rows >= 1")],
126        );
127        assert!(result.is_ok());
128        let output = result.expect("run_script should succeed");
129        let stdout = String::from_utf8_lossy(&output.stdout);
130        assert!(
131            stdout.contains("rows >= 1"),
132            "Expected 'rows >= 1' in stdout: {stdout}"
133        );
134    }
135
136    #[test]
137    fn test_run_script_nonexistent_script() {
138        let runner = RealCommandRunner;
139        // sh will run successfully but exit with error for non-existent script
140        let result = runner.run_script("/nonexistent/script.sh", "", &[]);
141        assert!(result.is_ok()); // sh spawns successfully
142        let output = result.expect("run_script should succeed");
143        assert!(!output.status.success()); // but the script fails
144    }
145}