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        if let Some(mut stdin) = child.stdin.take() {
64            stdin
65                .write_all(stdin_content.as_bytes())
66                .context("Failed to write to validator stdin")?;
67        }
68
69        child
70            .wait_with_output()
71            .context("Failed to wait for validator")
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    #![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)]
78
79    use super::*;
80
81    #[test]
82    fn test_real_command_runner_default() {
83        let runner = RealCommandRunner;
84        // Just verify it can be created
85        let _ = runner;
86    }
87
88    #[test]
89    fn test_real_command_runner_clone() {
90        let runner = RealCommandRunner;
91        let cloned = runner;
92        let _ = cloned;
93    }
94
95    #[test]
96    fn test_run_script_success() {
97        let runner = RealCommandRunner;
98        // Create a simple script that exits successfully
99        let result = runner.run_script("tests/fixtures/echo_validator.sh", "", &[]);
100        assert!(result.is_ok());
101    }
102
103    #[test]
104    fn test_run_script_with_stdin() {
105        let runner = RealCommandRunner;
106        // Use a real script that reads stdin
107        let result = runner.run_script("tests/fixtures/echo_validator.sh", "test input", &[]);
108        assert!(result.is_ok());
109    }
110
111    #[test]
112    fn test_run_script_with_env_vars() {
113        let runner = RealCommandRunner;
114        // Use echo_validator.sh which echoes VALIDATOR_ASSERTIONS env var
115        let result = runner.run_script(
116            "tests/fixtures/echo_validator.sh",
117            "{}",
118            &[("VALIDATOR_ASSERTIONS", "rows >= 1")],
119        );
120        assert!(result.is_ok());
121        let output = result.expect("run_script should succeed");
122        let stdout = String::from_utf8_lossy(&output.stdout);
123        assert!(
124            stdout.contains("rows >= 1"),
125            "Expected 'rows >= 1' in stdout: {stdout}"
126        );
127    }
128
129    #[test]
130    fn test_run_script_nonexistent_script() {
131        let runner = RealCommandRunner;
132        // sh will run successfully but exit with error for non-existent script
133        let result = runner.run_script("/nonexistent/script.sh", "", &[]);
134        assert!(result.is_ok()); // sh spawns successfully
135        let output = result.expect("run_script should succeed");
136        assert!(!output.status.success()); // but the script fails
137    }
138}