philiprehberger_assert_cmd/
lib.rs1use std::io::Write;
20use std::process::Command;
21use std::time::Duration;
22
23#[derive(Debug)]
25pub enum CmdError {
26 SpawnFailed(std::io::Error),
28 Timeout,
30 AssertionFailed {
32 context: String,
34 expected: String,
36 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
61pub 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
74pub fn cmd(program: &str) -> Cmd {
87 Cmd::new(program)
88}
89
90impl Cmd {
91 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 pub fn arg(mut self, arg: impl Into<String>) -> Self {
105 self.args.push(arg.into());
106 self
107 }
108
109 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 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 pub fn stdin(mut self, data: impl Into<String>) -> Self {
125 self.stdin_data = Some(data.into());
126 self
127 }
128
129 pub fn current_dir(mut self, dir: impl Into<String>) -> Self {
131 self.current_dir = Some(dir.into());
132 self
133 }
134
135 pub fn timeout(mut self, duration: Duration) -> Self {
138 self.timeout = Some(duration);
139 self
140 }
141
142 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 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#[derive(Debug, Clone)]
208pub struct CmdOutput {
209 pub status: i32,
211 pub stdout: String,
213 pub stderr: String,
215}
216
217impl CmdOutput {
218 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 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 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 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 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 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 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 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 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 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 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 pub fn stdout_lines(&self) -> Vec<&str> {
346 self.stdout.lines().collect()
347 }
348}
349
350fn 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 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}