Skip to main content

perfgate_fake/
process_runner.rs

1//! Fake process runner for deterministic testing.
2
3use crate::{AdapterError, CommandSpec, ProcessRunner, RunResult};
4use std::collections::HashMap;
5use std::sync::{Arc, Mutex};
6
7/// A process runner that returns pre-configured results for specific commands.
8///
9/// This is useful for testing code that depends on [`ProcessRunner`] without
10/// actually spawning processes. Results can be configured per-command or
11/// using a fallback.
12///
13/// # Thread Safety
14///
15/// All configuration methods are `&self` (not `&mut self`), making it safe
16/// to share a single instance across multiple threads in tests.
17///
18/// # Example
19///
20/// ```
21/// use perfgate_fake::FakeProcessRunner;
22/// use perfgate_adapters::{ProcessRunner, CommandSpec, RunResult};
23///
24/// let runner = FakeProcessRunner::new();
25///
26/// // Configure a result for a specific command
27/// runner.set_result(
28///     &["echo", "hello"],
29///     RunResult {
30///         wall_ms: 50,
31///         exit_code: 0,
32///         timed_out: false,
33///         cpu_ms: Some(10),
34///         page_faults: None,
35///         ctx_switches: None,
36///         max_rss_kb: Some(1024),
37///         io_read_bytes: None,
38///         io_write_bytes: None,
39///         network_packets: None,
40///         energy_uj: None,
41///         binary_bytes: None,
42///         stdout: b"hello\n".to_vec(),
43///         stderr: vec![],
44///     },
45/// );
46///
47/// // Get the history of executed commands
48/// assert!(runner.history().is_empty());
49/// ```
50#[derive(Debug, Default, Clone)]
51pub struct FakeProcessRunner {
52    results: Arc<Mutex<HashMap<String, RunResult>>>,
53    fallback: Arc<Mutex<Option<RunResult>>>,
54    history: Arc<Mutex<Vec<CommandSpec>>>,
55}
56
57impl FakeProcessRunner {
58    /// Create a new `FakeProcessRunner` with no configured results.
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Configure a result for a specific command argv.
64    ///
65    /// The argv is joined with spaces to create a lookup key.
66    /// When a matching command is run, the configured result is returned.
67    pub fn set_result(&self, argv: &[&str], result: RunResult) {
68        let key = argv.join(" ");
69        self.results.lock().expect("lock").insert(key, result);
70    }
71
72    /// Configure a fallback result for any command without a specific result.
73    ///
74    /// This is useful for tests that don't care about the exact command
75    /// but need some default behavior.
76    pub fn set_fallback(&self, result: RunResult) {
77        *self.fallback.lock().expect("lock") = Some(result);
78    }
79
80    /// Get the history of executed commands.
81    ///
82    /// Commands are recorded in the order they were run.
83    pub fn history(&self) -> Vec<CommandSpec> {
84        self.history.lock().expect("lock").clone()
85    }
86
87    /// Clear all configured results and history.
88    pub fn clear(&self) {
89        self.results.lock().expect("lock").clear();
90        *self.fallback.lock().expect("lock") = None;
91        self.history.lock().expect("lock").clear();
92    }
93
94    /// Get the number of times any command has been run.
95    pub fn call_count(&self) -> usize {
96        self.history.lock().expect("lock").len()
97    }
98
99    /// Check if a specific command was run.
100    pub fn was_run(&self, argv: &[&str]) -> bool {
101        let key = argv.join(" ");
102        self.history
103            .lock()
104            .expect("lock")
105            .iter()
106            .any(|spec| spec.argv.join(" ") == key)
107    }
108
109    /// Get the nth command that was run (0-indexed).
110    pub fn nth_call(&self, n: usize) -> Option<CommandSpec> {
111        self.history.lock().expect("lock").get(n).cloned()
112    }
113}
114
115impl ProcessRunner for FakeProcessRunner {
116    fn run(&self, spec: &CommandSpec) -> Result<RunResult, AdapterError> {
117        self.history.lock().expect("lock").push(spec.clone());
118
119        let key = spec.argv.join(" ");
120        let results = self.results.lock().expect("lock");
121        if let Some(res) = results.get(&key) {
122            return Ok(res.clone());
123        }
124
125        let fallback = self.fallback.lock().expect("lock");
126        if let Some(res) = &*fallback {
127            return Ok(res.clone());
128        }
129
130        Err(AdapterError::Other(format!(
131            "FakeProcessRunner: no result configured for command: {:?}",
132            spec.argv
133        )))
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn make_result(exit_code: i32, wall_ms: u64) -> RunResult {
142        RunResult {
143            wall_ms,
144            exit_code,
145            timed_out: false,
146            cpu_ms: None,
147            page_faults: None,
148            ctx_switches: None,
149            max_rss_kb: None,
150            io_read_bytes: None,
151            io_write_bytes: None,
152            network_packets: None,
153            energy_uj: None,
154            binary_bytes: None,
155            stdout: vec![],
156            stderr: vec![],
157        }
158    }
159
160    fn make_spec(argv: Vec<&str>) -> CommandSpec {
161        CommandSpec {
162            name: argv.first().unwrap_or(&"unknown").to_string(),
163            argv: argv.into_iter().map(String::from).collect(),
164            cwd: None,
165            env: vec![],
166            timeout: None,
167            output_cap_bytes: 1024,
168        }
169    }
170
171    #[test]
172    fn new_runner_is_empty() {
173        let runner = FakeProcessRunner::new();
174        assert!(runner.history().is_empty());
175        assert_eq!(runner.call_count(), 0);
176    }
177
178    #[test]
179    fn set_result_returns_configured() {
180        let runner = FakeProcessRunner::new();
181        runner.set_result(&["echo", "hello"], make_result(0, 50));
182
183        let result = runner.run(&make_spec(vec!["echo", "hello"])).unwrap();
184        assert_eq!(result.exit_code, 0);
185        assert_eq!(result.wall_ms, 50);
186    }
187
188    #[test]
189    fn fallback_is_used_when_no_match() {
190        let runner = FakeProcessRunner::new();
191        runner.set_fallback(make_result(42, 100));
192
193        let result = runner.run(&make_spec(vec!["unknown"])).unwrap();
194        assert_eq!(result.exit_code, 42);
195        assert_eq!(result.wall_ms, 100);
196    }
197
198    #[test]
199    fn specific_result_takes_precedence_over_fallback() {
200        let runner = FakeProcessRunner::new();
201        runner.set_result(&["echo"], make_result(0, 10));
202        runner.set_fallback(make_result(1, 999));
203
204        let result = runner.run(&make_spec(vec!["echo"])).unwrap();
205        assert_eq!(result.exit_code, 0);
206    }
207
208    #[test]
209    fn error_when_no_result_configured() {
210        let runner = FakeProcessRunner::new();
211        let result = runner.run(&make_spec(vec!["unknown"]));
212        assert!(result.is_err());
213    }
214
215    #[test]
216    fn history_records_commands() {
217        let runner = FakeProcessRunner::new();
218        runner.set_fallback(make_result(0, 0));
219
220        runner.run(&make_spec(vec!["cmd1"])).unwrap();
221        runner.run(&make_spec(vec!["cmd2", "arg"])).unwrap();
222
223        let history = runner.history();
224        assert_eq!(history.len(), 2);
225        assert_eq!(history[0].argv, vec!["cmd1"]);
226        assert_eq!(history[1].argv, vec!["cmd2", "arg"]);
227    }
228
229    #[test]
230    fn was_run_checks_history() {
231        let runner = FakeProcessRunner::new();
232        runner.set_fallback(make_result(0, 0));
233
234        assert!(!runner.was_run(&["echo"]));
235
236        runner.run(&make_spec(vec!["echo", "hello"])).unwrap();
237
238        assert!(runner.was_run(&["echo", "hello"]));
239        assert!(!runner.was_run(&["echo", "goodbye"]));
240    }
241
242    #[test]
243    fn nth_call_returns_correct_command() {
244        let runner = FakeProcessRunner::new();
245        runner.set_fallback(make_result(0, 0));
246
247        runner.run(&make_spec(vec!["first"])).unwrap();
248        runner.run(&make_spec(vec!["second"])).unwrap();
249
250        assert_eq!(runner.nth_call(0).unwrap().argv, vec!["first"]);
251        assert_eq!(runner.nth_call(1).unwrap().argv, vec!["second"]);
252        assert!(runner.nth_call(2).is_none());
253    }
254
255    #[test]
256    fn clear_resets_everything() {
257        let runner = FakeProcessRunner::new();
258        runner.set_result(&["cmd"], make_result(0, 0));
259        runner.set_fallback(make_result(1, 1));
260        runner.run(&make_spec(vec!["cmd"])).unwrap();
261
262        runner.clear();
263
264        assert!(runner.history().is_empty());
265        assert!(runner.run(&make_spec(vec!["cmd"])).is_err());
266    }
267
268    #[test]
269    fn thread_safe_sharing() {
270        use std::sync::Arc;
271        use std::thread;
272
273        let runner = Arc::new(FakeProcessRunner::new());
274        runner.set_fallback(make_result(0, 0));
275
276        let handles: Vec<_> = (0..4)
277            .map(|i| {
278                let r = runner.clone();
279                thread::spawn(move || {
280                    r.run(&make_spec(vec!["cmd", &i.to_string()])).unwrap();
281                })
282            })
283            .collect();
284
285        for h in handles {
286            h.join().unwrap();
287        }
288
289        assert_eq!(runner.call_count(), 4);
290    }
291}