Skip to main content

philiprehberger_assert_cmd/
lib.rs

1//! Ergonomic CLI binary integration testing with fluent assertions on stdout, stderr, and exit code.
2//!
3//! `philiprehberger-assert-cmd` provides a fluent builder for spawning CLI processes and
4//! asserting on their output. Zero dependencies — uses only `std::process::Command`.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use philiprehberger_assert_cmd::cmd;
10//!
11//! cmd("echo")
12//!     .arg("hello")
13//!     .run()
14//!     .unwrap()
15//!     .assert_success()
16//!     .assert_stdout_contains("hello");
17//! ```
18
19use std::io::Write;
20use std::process::Command;
21use std::time::Duration;
22
23/// Errors that can occur when running a command.
24#[derive(Debug)]
25pub enum CmdError {
26    /// The process failed to spawn.
27    SpawnFailed(std::io::Error),
28    /// The process exceeded the configured timeout.
29    Timeout,
30    /// An assertion on the command output failed.
31    AssertionFailed {
32        /// What was being checked.
33        context: String,
34        /// The expected value.
35        expected: String,
36        /// The actual value.
37        actual: String,
38    },
39}
40
41impl std::fmt::Display for CmdError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            CmdError::SpawnFailed(e) => write!(f, "failed to spawn process: {}", e),
45            CmdError::Timeout => write!(f, "process timed out"),
46            CmdError::AssertionFailed {
47                context,
48                expected,
49                actual,
50            } => write!(
51                f,
52                "assertion failed: {}\n  expected: {}\n  actual:   {}",
53                context, expected, actual
54            ),
55        }
56    }
57}
58
59impl std::error::Error for CmdError {}
60
61/// A builder for configuring and running a CLI command.
62///
63/// Use [`cmd`] or [`Cmd::new`] to create a new instance, then chain builder
64/// methods before calling [`Cmd::run`].
65pub struct Cmd {
66    program: String,
67    args: Vec<String>,
68    env: Vec<(String, String)>,
69    stdin_data: Option<String>,
70    current_dir: Option<String>,
71    timeout: Option<Duration>,
72}
73
74/// Create a new [`Cmd`] builder for the given program.
75///
76/// This is a convenience function equivalent to [`Cmd::new`].
77///
78/// # Example
79///
80/// ```no_run
81/// use philiprehberger_assert_cmd::cmd;
82///
83/// let output = cmd("rustc").arg("--version").run().unwrap();
84/// output.assert_success();
85/// ```
86pub fn cmd(program: &str) -> Cmd {
87    Cmd::new(program)
88}
89
90impl Cmd {
91    /// Create a new `Cmd` builder for the given program.
92    pub fn new(program: &str) -> Self {
93        Self {
94            program: program.to_string(),
95            args: Vec::new(),
96            env: Vec::new(),
97            stdin_data: None,
98            current_dir: None,
99            timeout: None,
100        }
101    }
102
103    /// Append a single argument to the command.
104    pub fn arg(mut self, arg: impl Into<String>) -> Self {
105        self.args.push(arg.into());
106        self
107    }
108
109    /// Append multiple arguments to the command.
110    pub fn args(mut self, args: &[&str]) -> Self {
111        for a in args {
112            self.args.push((*a).to_string());
113        }
114        self
115    }
116
117    /// Set an environment variable for the command.
118    pub fn env(mut self, key: &str, value: &str) -> Self {
119        self.env.push((key.to_string(), value.to_string()));
120        self
121    }
122
123    /// Provide data to pipe into the command's stdin.
124    pub fn stdin(mut self, data: impl Into<String>) -> Self {
125        self.stdin_data = Some(data.into());
126        self
127    }
128
129    /// Set the working directory for the command.
130    pub fn current_dir(mut self, dir: impl Into<String>) -> Self {
131        self.current_dir = Some(dir.into());
132        self
133    }
134
135    /// Set a timeout for the command. If the process exceeds this duration,
136    /// [`CmdError::Timeout`] is returned.
137    pub fn timeout(mut self, duration: Duration) -> Self {
138        self.timeout = Some(duration);
139        self
140    }
141
142    /// Execute the command and return a [`CmdOutput`] on success.
143    pub fn run(&self) -> Result<CmdOutput, CmdError> {
144        let mut command = Command::new(&self.program);
145        command.args(&self.args);
146
147        for (key, value) in &self.env {
148            command.env(key, value);
149        }
150
151        if let Some(dir) = &self.current_dir {
152            command.current_dir(dir);
153        }
154
155        if self.stdin_data.is_some() {
156            command.stdin(std::process::Stdio::piped());
157        }
158
159        command.stdout(std::process::Stdio::piped());
160        command.stderr(std::process::Stdio::piped());
161
162        let mut child = command.spawn().map_err(CmdError::SpawnFailed)?;
163
164        if let Some(data) = &self.stdin_data {
165            if let Some(ref mut stdin) = child.stdin {
166                let _ = stdin.write_all(data.as_bytes());
167            }
168            // Drop stdin to signal EOF
169            child.stdin.take();
170        }
171
172        if let Some(timeout) = self.timeout {
173            let start = std::time::Instant::now();
174            loop {
175                match child.try_wait() {
176                    Ok(Some(_)) => break,
177                    Ok(None) => {
178                        if start.elapsed() >= timeout {
179                            let _ = child.kill();
180                            return Err(CmdError::Timeout);
181                        }
182                        std::thread::sleep(Duration::from_millis(10));
183                    }
184                    Err(e) => return Err(CmdError::SpawnFailed(e)),
185                }
186            }
187        }
188
189        let output = child.wait_with_output().map_err(CmdError::SpawnFailed)?;
190
191        let status = output.status.code().unwrap_or(-1);
192        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
193        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
194
195        Ok(CmdOutput {
196            status,
197            stdout,
198            stderr,
199        })
200    }
201}
202
203/// The result of running a command, with chainable assertion methods.
204///
205/// All assertion methods return `&Self` so they can be chained. They panic
206/// with a descriptive message if the assertion fails.
207#[derive(Debug, Clone)]
208pub struct CmdOutput {
209    /// The exit code of the process.
210    pub status: i32,
211    /// The captured standard output.
212    pub stdout: String,
213    /// The captured standard error.
214    pub stderr: String,
215}
216
217impl CmdOutput {
218    /// Assert that the command exited with code 0.
219    pub fn assert_success(&self) -> &Self {
220        if self.status != 0 {
221            panic!(
222                "assertion failed: expected success (exit code 0), got exit code {}\nstdout: {}\nstderr: {}",
223                self.status, self.stdout, self.stderr
224            );
225        }
226        self
227    }
228
229    /// Assert that the command exited with a non-zero exit code.
230    pub fn assert_failure(&self) -> &Self {
231        if self.status == 0 {
232            panic!(
233                "assertion failed: expected failure (non-zero exit code), got exit code 0\nstdout: {}",
234                self.stdout
235            );
236        }
237        self
238    }
239
240    /// Assert that the command exited with the given exit code.
241    pub fn assert_exit_code(&self, code: i32) -> &Self {
242        if self.status != code {
243            panic!(
244                "assertion failed: expected exit code {}, got {}\nstdout: {}\nstderr: {}",
245                code, self.status, self.stdout, self.stderr
246            );
247        }
248        self
249    }
250
251    /// Assert that stdout contains the given substring.
252    pub fn assert_stdout_contains(&self, substring: &str) -> &Self {
253        if !self.stdout.contains(substring) {
254            panic!(
255                "assertion failed: stdout does not contain {:?}\nstdout: {:?}",
256                substring, self.stdout
257            );
258        }
259        self
260    }
261
262    /// Assert that stdout equals the given string exactly.
263    pub fn assert_stdout_equals(&self, expected: &str) -> &Self {
264        if self.stdout != expected {
265            panic!(
266                "assertion failed: stdout does not equal expected\n  expected: {:?}\n  actual:   {:?}",
267                expected, self.stdout
268            );
269        }
270        self
271    }
272
273    /// Assert that stdout is empty.
274    pub fn assert_stdout_is_empty(&self) -> &Self {
275        if !self.stdout.is_empty() {
276            panic!(
277                "assertion failed: expected stdout to be empty, got: {:?}",
278                self.stdout
279            );
280        }
281        self
282    }
283
284    /// Assert that stdout has exactly the given number of non-empty lines.
285    pub fn assert_stdout_line_count(&self, count: usize) -> &Self {
286        let lines: Vec<&str> = self.stdout.lines().collect();
287        if lines.len() != count {
288            panic!(
289                "assertion failed: expected {} stdout line(s), got {}\nstdout: {:?}",
290                count,
291                lines.len(),
292                self.stdout
293            );
294        }
295        self
296    }
297
298    /// Assert that stdout matches the given glob pattern.
299    ///
300    /// Supports `*` (any number of characters) and `?` (exactly one character).
301    pub fn assert_stdout_matches(&self, pattern: &str) -> &Self {
302        if !glob_match(pattern, self.stdout.trim()) {
303            panic!(
304                "assertion failed: stdout does not match pattern {:?}\nstdout: {:?}",
305                pattern, self.stdout
306            );
307        }
308        self
309    }
310
311    /// Assert that stderr contains the given substring.
312    pub fn assert_stderr_contains(&self, substring: &str) -> &Self {
313        if !self.stderr.contains(substring) {
314            panic!(
315                "assertion failed: stderr does not contain {:?}\nstderr: {:?}",
316                substring, self.stderr
317            );
318        }
319        self
320    }
321
322    /// Assert that stderr equals the given string exactly.
323    pub fn assert_stderr_equals(&self, expected: &str) -> &Self {
324        if self.stderr != expected {
325            panic!(
326                "assertion failed: stderr does not equal expected\n  expected: {:?}\n  actual:   {:?}",
327                expected, self.stderr
328            );
329        }
330        self
331    }
332
333    /// Assert that stderr is empty.
334    pub fn assert_stderr_is_empty(&self) -> &Self {
335        if !self.stderr.is_empty() {
336            panic!(
337                "assertion failed: expected stderr to be empty, got: {:?}",
338                self.stderr
339            );
340        }
341        self
342    }
343
344    /// Split stdout into lines.
345    pub fn stdout_lines(&self) -> Vec<&str> {
346        self.stdout.lines().collect()
347    }
348}
349
350/// Simple glob matching supporting `*` (any characters) and `?` (one character).
351fn glob_match(pattern: &str, text: &str) -> bool {
352    let pat: Vec<char> = pattern.chars().collect();
353    let txt: Vec<char> = text.chars().collect();
354    glob_match_inner(&pat, &txt)
355}
356
357fn glob_match_inner(pattern: &[char], text: &[char]) -> bool {
358    if pattern.is_empty() {
359        return text.is_empty();
360    }
361
362    match pattern[0] {
363        '*' => {
364            // Try matching zero or more characters
365            for i in 0..=text.len() {
366                if glob_match_inner(&pattern[1..], &text[i..]) {
367                    return true;
368                }
369            }
370            false
371        }
372        '?' => {
373            if text.is_empty() {
374                false
375            } else {
376                glob_match_inner(&pattern[1..], &text[1..])
377            }
378        }
379        c => {
380            if text.is_empty() || text[0] != c {
381                false
382            } else {
383                glob_match_inner(&pattern[1..], &text[1..])
384            }
385        }
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[cfg(windows)]
394    fn echo_cmd(text: &str) -> Cmd {
395        Cmd::new("cmd").arg("/C").arg(format!("echo {}", text))
396    }
397
398    #[cfg(not(windows))]
399    fn echo_cmd(text: &str) -> Cmd {
400        Cmd::new("echo").arg(text)
401    }
402
403    #[test]
404    fn test_echo_success() {
405        let output = echo_cmd("hello").run().unwrap();
406        output.assert_success();
407    }
408
409    #[test]
410    fn test_assert_success() {
411        let output = cmd("rustc").arg("--version").run().unwrap();
412        output.assert_success();
413    }
414
415    #[test]
416    fn test_assert_failure() {
417        let output = cmd("rustc").arg("--invalid-flag-xyz").run().unwrap();
418        output.assert_failure();
419    }
420
421    #[test]
422    fn test_assert_stdout_contains() {
423        let output = echo_cmd("hello world").run().unwrap();
424        output.assert_stdout_contains("hello");
425    }
426
427    #[test]
428    fn test_assert_stdout_equals() {
429        let output = echo_cmd("hello").run().unwrap();
430        let trimmed = output.stdout.trim().to_string();
431        let expected_output = CmdOutput {
432            status: 0,
433            stdout: format!("{}\n", trimmed),
434            stderr: String::new(),
435        };
436        expected_output.assert_stdout_equals(&format!("{}\n", trimmed));
437    }
438
439    #[test]
440    fn test_assert_stderr_is_empty() {
441        let output = cmd("rustc").arg("--version").run().unwrap();
442        output.assert_stderr_is_empty();
443    }
444
445    #[test]
446    fn test_assert_exit_code() {
447        let output = cmd("rustc").arg("--version").run().unwrap();
448        output.assert_exit_code(0);
449    }
450
451    #[test]
452    fn test_env_variable() {
453        #[cfg(windows)]
454        let output = cmd("cmd")
455            .args(&["/C", "echo %TEST_VAR%"])
456            .env("TEST_VAR", "my_value")
457            .run()
458            .unwrap();
459
460        #[cfg(not(windows))]
461        let output = cmd("sh")
462            .args(&["-c", "echo $TEST_VAR"])
463            .env("TEST_VAR", "my_value")
464            .run()
465            .unwrap();
466
467        output.assert_success().assert_stdout_contains("my_value");
468    }
469
470    #[test]
471    fn test_stdin_piping() {
472        #[cfg(windows)]
473        let output = cmd("findstr")
474            .arg("hello")
475            .stdin("hello world\ngoodbye\n")
476            .run()
477            .unwrap();
478
479        #[cfg(not(windows))]
480        let output = cmd("cat")
481            .stdin("hello world\n")
482            .run()
483            .unwrap();
484
485        output.assert_success().assert_stdout_contains("hello");
486    }
487
488    #[test]
489    fn test_glob_matching() {
490        assert!(glob_match("hello*", "hello world"));
491        assert!(glob_match("hello*", "hello"));
492        assert!(glob_match("*world", "hello world"));
493        assert!(glob_match("h?llo", "hello"));
494        assert!(!glob_match("h?llo", "hllo"));
495        assert!(glob_match("*", "anything"));
496        assert!(glob_match("*", ""));
497        assert!(glob_match("a*b*c", "aXXbYYc"));
498        assert!(!glob_match("a*b*c", "aXXbYY"));
499    }
500
501    #[test]
502    fn test_stdout_lines() {
503        let output = CmdOutput {
504            status: 0,
505            stdout: "line1\nline2\nline3".to_string(),
506            stderr: String::new(),
507        };
508        assert_eq!(output.stdout_lines(), vec!["line1", "line2", "line3"]);
509    }
510
511    #[test]
512    fn test_assert_stdout_line_count() {
513        let output = CmdOutput {
514            status: 0,
515            stdout: "line1\nline2\nline3".to_string(),
516            stderr: String::new(),
517        };
518        output.assert_stdout_line_count(3);
519    }
520
521    #[test]
522    fn test_assert_stdout_is_empty() {
523        let output = CmdOutput {
524            status: 0,
525            stdout: String::new(),
526            stderr: String::new(),
527        };
528        output.assert_stdout_is_empty();
529    }
530
531    #[test]
532    fn test_assert_stdout_matches() {
533        let output = echo_cmd("hello world").run().unwrap();
534        output.assert_stdout_matches("hello*");
535    }
536
537    #[test]
538    fn test_current_dir() {
539        #[cfg(windows)]
540        let output = cmd("cmd")
541            .args(&["/C", "cd"])
542            .current_dir("C:\\")
543            .run()
544            .unwrap();
545
546        #[cfg(not(windows))]
547        let output = cmd("pwd")
548            .current_dir("/tmp")
549            .run()
550            .unwrap();
551
552        output.assert_success();
553    }
554
555    #[test]
556    #[should_panic(expected = "assertion failed")]
557    fn test_assert_success_panics_on_failure() {
558        let output = CmdOutput {
559            status: 1,
560            stdout: String::new(),
561            stderr: String::new(),
562        };
563        output.assert_success();
564    }
565
566    #[test]
567    #[should_panic(expected = "assertion failed")]
568    fn test_assert_failure_panics_on_success() {
569        let output = CmdOutput {
570            status: 0,
571            stdout: String::new(),
572            stderr: String::new(),
573        };
574        output.assert_failure();
575    }
576
577    #[test]
578    fn test_chaining() {
579        let output = cmd("rustc").arg("--version").run().unwrap();
580        output
581            .assert_success()
582            .assert_exit_code(0)
583            .assert_stdout_contains("rustc")
584            .assert_stderr_is_empty();
585    }
586}