Skip to main content

eggress_system_proxy/
command_runner.rs

1use std::process::Output;
2
3/// Trait for executing system commands, enabling test injection.
4///
5/// Production code uses `RealCommandRunner`; tests inject
6/// `MockCommandRunner` to verify behavior without side effects.
7pub trait CommandRunner {
8    /// Execute a command with the given arguments and return the output.
9    fn run(&self, program: &str, args: &[&str]) -> Result<Output, std::io::Error>;
10}
11
12/// Production command runner that executes real system commands.
13pub struct RealCommandRunner;
14
15impl CommandRunner for RealCommandRunner {
16    fn run(&self, program: &str, args: &[&str]) -> Result<Output, std::io::Error> {
17        std::process::Command::new(program).args(args).output()
18    }
19}
20
21/// Mock command runner for testing.
22pub struct MockCommandRunner {
23    responses: Vec<(String, Vec<String>, Result<Output, std::io::Error>)>,
24    calls: std::sync::Mutex<Vec<(String, Vec<String>)>>,
25}
26
27impl MockCommandRunner {
28    /// Create a new mock with no predefined responses.
29    pub fn new() -> Self {
30        Self {
31            responses: Vec::new(),
32            calls: std::sync::Mutex::new(Vec::new()),
33        }
34    }
35
36    /// Add a predefined response for a command.
37    pub fn add_response(
38        mut self,
39        program: &str,
40        args: Vec<String>,
41        result: Result<Output, std::io::Error>,
42    ) -> Self {
43        self.responses.push((program.to_string(), args, result));
44        self
45    }
46
47    /// Add a response matching any invocation of a program.
48    pub fn add_always(self, program: &str, result: Result<Output, std::io::Error>) -> Self {
49        self.add_response(program, Vec::new(), result)
50    }
51
52    /// Get all recorded calls.
53    pub fn calls(&self) -> Vec<(String, Vec<String>)> {
54        self.calls.lock().unwrap_or_else(|e| e.into_inner()).clone()
55    }
56}
57
58impl Default for MockCommandRunner {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl CommandRunner for MockCommandRunner {
65    fn run(&self, program: &str, args: &[&str]) -> Result<Output, std::io::Error> {
66        let args_vec: Vec<String> = args.iter().map(|s| s.to_string()).collect();
67        self.calls
68            .lock()
69            .unwrap_or_else(|e| e.into_inner())
70            .push((program.to_string(), args_vec.clone()));
71
72        for (resp_prog, resp_args, resp_result) in &self.responses {
73            if resp_prog == program && (resp_args.is_empty() || resp_args == &args_vec) {
74                return match resp_result {
75                    Ok(output) => Ok(Output {
76                        status: output.status,
77                        stdout: output.stdout.clone(),
78                        stderr: output.stderr.clone(),
79                    }),
80                    Err(e) => Err(std::io::Error::new(e.kind(), e.to_string())),
81                };
82            }
83        }
84
85        Err(std::io::Error::new(
86            std::io::ErrorKind::NotFound,
87            format!("no mock response for {program}"),
88        ))
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    fn success_exit_status() -> std::process::ExitStatus {
97        #[cfg(unix)]
98        {
99            use std::os::unix::process::ExitStatusExt;
100            std::process::ExitStatus::from_raw(0)
101        }
102        #[cfg(not(unix))]
103        {
104            std::process::ExitStatus::default()
105        }
106    }
107
108    #[test]
109    fn mock_runner_returns_predefined_response() {
110        let runner = MockCommandRunner::new().add_always(
111            "echo",
112            Ok(Output {
113                status: success_exit_status(),
114                stdout: b"hello\n".to_vec(),
115                stderr: Vec::new(),
116            }),
117        );
118
119        let output = runner.run("echo", &["test"]).unwrap();
120        assert_eq!(output.stdout, b"hello\n");
121    }
122
123    #[test]
124    fn mock_runner_records_calls() {
125        let runner = MockCommandRunner::new().add_always(
126            "ls",
127            Ok(Output {
128                status: success_exit_status(),
129                stdout: Vec::new(),
130                stderr: Vec::new(),
131            }),
132        );
133
134        let _ = runner.run("ls", &["-la"]);
135        let _ = runner.run("ls", &["/tmp"]);
136
137        let calls = runner.calls();
138        assert_eq!(calls.len(), 2);
139        assert_eq!(calls[0].0, "ls");
140        assert_eq!(calls[1].1, vec!["/tmp".to_string()]);
141    }
142
143    #[test]
144    fn mock_runner_error_for_unknown_command() {
145        let runner = MockCommandRunner::new();
146        let result = runner.run("nonexistent", &[]);
147        assert!(result.is_err());
148    }
149
150    #[test]
151    fn real_runner_executes_command() {
152        let runner = RealCommandRunner;
153        let output = runner.run("echo", &["test"]).unwrap();
154        assert!(output.status.success());
155        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "test");
156    }
157}