cmd_proc/
lib.rs

1use std::borrow::Cow;
2use std::ffi::OsStr;
3
4#[derive(Debug, thiserror::Error)]
5#[error("Command execution failed: io_error={io_error:?}, exit_status={exit_status:?}")]
6pub struct CommandError {
7    pub io_error: Option<std::io::Error>,
8    pub exit_status: Option<std::process::ExitStatus>,
9}
10
11fn write_stdin(
12    child: &mut std::process::Child,
13    stdin_data: Option<Vec<u8>>,
14) -> Result<(), CommandError> {
15    use std::io::Write;
16
17    if let Some(data) = stdin_data {
18        child
19            .stdin
20            .take()
21            .unwrap()
22            .write_all(&data)
23            .map_err(|io_error| CommandError {
24                io_error: Some(io_error),
25                exit_status: None,
26            })?;
27    }
28
29    Ok(())
30}
31
32fn run_and_wait(
33    mut child: std::process::Child,
34    stdin_data: Option<Vec<u8>>,
35    start: std::time::Instant,
36) -> Result<std::process::Output, CommandError> {
37    write_stdin(&mut child, stdin_data)?;
38
39    let output = child.wait_with_output().map_err(|io_error| CommandError {
40        io_error: Some(io_error),
41        exit_status: None,
42    })?;
43
44    log::debug!(
45        "exit_status={:?} runtime={:?}",
46        output.status,
47        start.elapsed()
48    );
49
50    Ok(output)
51}
52
53fn run_and_wait_status(
54    mut child: std::process::Child,
55    stdin_data: Option<Vec<u8>>,
56    start: std::time::Instant,
57) -> Result<std::process::ExitStatus, CommandError> {
58    write_stdin(&mut child, stdin_data)?;
59
60    let status = child.wait().map_err(|io_error| CommandError {
61        io_error: Some(io_error),
62        exit_status: None,
63    })?;
64
65    log::debug!("exit_status={:?} runtime={:?}", status, start.elapsed());
66
67    Ok(status)
68}
69
70/// Validated environment variable name.
71///
72/// Ensures the name is non-empty and does not contain `=`.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct EnvVariableName<'a>(Cow<'a, str>);
75
76impl EnvVariableName<'_> {
77    #[must_use]
78    pub fn as_str(&self) -> &str {
79        &self.0
80    }
81}
82
83impl AsRef<OsStr> for EnvVariableName<'_> {
84    fn as_ref(&self) -> &OsStr {
85        self.0.as_ref().as_ref()
86    }
87}
88
89impl EnvVariableName<'static> {
90    /// Compile-time validated environment variable name.
91    ///
92    /// # Panics
93    ///
94    /// Panics at compile time if the name is empty or contains `=`.
95    #[must_use]
96    pub const fn from_static(s: &'static str) -> Self {
97        if s.is_empty() {
98            panic!("Environment variable name cannot be empty");
99        }
100        let bytes = s.as_bytes();
101        let mut i = 0;
102        while i < bytes.len() {
103            if bytes[i] == b'=' {
104                panic!("Environment variable name cannot contain '='");
105            }
106            i += 1;
107        }
108        Self(Cow::Borrowed(s))
109    }
110}
111
112#[derive(Debug, thiserror::Error)]
113pub enum EnvVariableNameError {
114    #[error("Environment variable name cannot be empty")]
115    Empty,
116    #[error("Environment variable name cannot contain '='")]
117    ContainsEquals,
118}
119
120impl std::str::FromStr for EnvVariableName<'static> {
121    type Err = EnvVariableNameError;
122
123    fn from_str(s: &str) -> Result<Self, Self::Err> {
124        if s.is_empty() {
125            return Err(EnvVariableNameError::Empty);
126        }
127        if s.contains('=') {
128            return Err(EnvVariableNameError::ContainsEquals);
129        }
130        Ok(Self(Cow::Owned(s.to_string())))
131    }
132}
133
134/// Output from a command execution, including both streams and exit status.
135///
136/// Unlike `Capture`, this does not treat non-zero exit as an error.
137/// Use this when you need to inspect stderr on failure.
138#[derive(Debug)]
139pub struct Output {
140    pub stdout: Vec<u8>,
141    pub stderr: Vec<u8>,
142    pub status: std::process::ExitStatus,
143}
144
145impl Output {
146    /// Returns true if the command exited successfully.
147    #[must_use]
148    pub fn success(&self) -> bool {
149        self.status.success()
150    }
151
152    /// Converts stdout to a UTF-8 string.
153    ///
154    /// # Errors
155    ///
156    /// Returns an error if stdout is not valid UTF-8.
157    pub fn into_stdout_string(self) -> Result<String, std::string::FromUtf8Error> {
158        String::from_utf8(self.stdout)
159    }
160
161    /// Converts stderr to a UTF-8 string.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if stderr is not valid UTF-8.
166    pub fn into_stderr_string(self) -> Result<String, std::string::FromUtf8Error> {
167        String::from_utf8(self.stderr)
168    }
169}
170
171/// Which stream to capture from a command.
172#[derive(Clone, Copy)]
173enum CaptureStream {
174    Stdout,
175    Stderr,
176}
177
178/// Stdio configuration for spawned processes.
179#[derive(Clone, Copy, Default)]
180pub enum Stdio {
181    /// Pipe the stream, allowing reading/writing from the parent process.
182    Piped,
183    /// Inherit the stream from the parent process.
184    #[default]
185    Inherit,
186    /// Redirect to /dev/null.
187    Null,
188}
189
190impl From<Stdio> for std::process::Stdio {
191    fn from(stdio: Stdio) -> Self {
192        match stdio {
193            Stdio::Piped => std::process::Stdio::piped(),
194            Stdio::Inherit => std::process::Stdio::inherit(),
195            Stdio::Null => std::process::Stdio::null(),
196        }
197    }
198}
199
200/// Builder for spawning a child process.
201///
202/// Created by [`Command::spawn`]. Configure stdin/stdout/stderr, then call [`Spawn::run`].
203pub struct Spawn {
204    command: Command,
205    stdin: Stdio,
206    stdout: Stdio,
207    stderr: Stdio,
208}
209
210impl Spawn {
211    fn new(command: Command) -> Self {
212        Self {
213            command,
214            stdin: Stdio::Inherit,
215            stdout: Stdio::Inherit,
216            stderr: Stdio::Inherit,
217        }
218    }
219
220    /// Configure stdin for the child process.
221    #[must_use]
222    pub fn stdin(mut self, stdio: Stdio) -> Self {
223        self.stdin = stdio;
224        self
225    }
226
227    /// Configure stdout for the child process.
228    #[must_use]
229    pub fn stdout(mut self, stdio: Stdio) -> Self {
230        self.stdout = stdio;
231        self
232    }
233
234    /// Configure stderr for the child process.
235    #[must_use]
236    pub fn stderr(mut self, stdio: Stdio) -> Self {
237        self.stderr = stdio;
238        self
239    }
240
241    /// Spawn the child process.
242    pub fn run(mut self) -> Result<Child, CommandError> {
243        log::debug!("{:#?}", self.command.inner);
244
245        self.command.inner.stdin(self.stdin);
246        self.command.inner.stdout(self.stdout);
247        self.command.inner.stderr(self.stderr);
248
249        let inner = self
250            .command
251            .inner
252            .spawn()
253            .map_err(|io_error| CommandError {
254                io_error: Some(io_error),
255                exit_status: None,
256            })?;
257
258        Ok(Child { inner })
259    }
260}
261
262/// A running child process.
263///
264/// Created by [`Spawn::run`].
265#[derive(Debug)]
266pub struct Child {
267    inner: std::process::Child,
268}
269
270impl Child {
271    /// Returns a mutable reference to the child's stdin handle.
272    pub fn stdin(&mut self) -> Option<&mut std::process::ChildStdin> {
273        self.inner.stdin.as_mut()
274    }
275
276    /// Returns a mutable reference to the child's stdout handle.
277    pub fn stdout(&mut self) -> Option<&mut std::process::ChildStdout> {
278        self.inner.stdout.as_mut()
279    }
280
281    /// Returns a mutable reference to the child's stderr handle.
282    pub fn stderr(&mut self) -> Option<&mut std::process::ChildStderr> {
283        self.inner.stderr.as_mut()
284    }
285
286    /// Takes ownership of the child's stdin handle.
287    pub fn take_stdin(&mut self) -> Option<std::process::ChildStdin> {
288        self.inner.stdin.take()
289    }
290
291    /// Takes ownership of the child's stdout handle.
292    pub fn take_stdout(&mut self) -> Option<std::process::ChildStdout> {
293        self.inner.stdout.take()
294    }
295
296    /// Takes ownership of the child's stderr handle.
297    pub fn take_stderr(&mut self) -> Option<std::process::ChildStderr> {
298        self.inner.stderr.take()
299    }
300
301    /// Waits for the child to exit and returns its exit status.
302    pub fn wait(mut self) -> Result<std::process::ExitStatus, CommandError> {
303        self.inner.wait().map_err(|io_error| CommandError {
304            io_error: Some(io_error),
305            exit_status: None,
306        })
307    }
308
309    /// Simultaneously waits for the child to exit and collects all output.
310    pub fn wait_with_output(self) -> Result<Output, CommandError> {
311        let output = self
312            .inner
313            .wait_with_output()
314            .map_err(|io_error| CommandError {
315                io_error: Some(io_error),
316                exit_status: None,
317            })?;
318
319        Ok(Output {
320            stdout: output.stdout,
321            stderr: output.stderr,
322            status: output.status,
323        })
324    }
325}
326
327/// Builder for capturing command output.
328pub struct Capture {
329    command: Command,
330    stream: CaptureStream,
331}
332
333impl Capture {
334    fn new(command: Command, stream: CaptureStream) -> Self {
335        Self { command, stream }
336    }
337
338    /// Execute the command and return captured output as bytes.
339    pub fn bytes(mut self) -> Result<Vec<u8>, CommandError> {
340        use std::process::Stdio;
341
342        log::debug!("{:#?}", self.command.inner);
343
344        self.command.inner.stdout(Stdio::piped());
345        self.command.inner.stderr(Stdio::piped());
346
347        if self.command.stdin_data.is_some() {
348            self.command.inner.stdin(Stdio::piped());
349        }
350
351        let start = std::time::Instant::now();
352
353        let child = self
354            .command
355            .inner
356            .spawn()
357            .map_err(|io_error| CommandError {
358                io_error: Some(io_error),
359                exit_status: None,
360            })?;
361
362        let output = run_and_wait(child, self.command.stdin_data, start)?;
363
364        if output.status.success() {
365            Ok(match self.stream {
366                CaptureStream::Stdout => output.stdout,
367                CaptureStream::Stderr => output.stderr,
368            })
369        } else {
370            Err(CommandError {
371                io_error: None,
372                exit_status: Some(output.status),
373            })
374        }
375    }
376
377    /// Execute the command and return captured output as a UTF-8 string.
378    pub fn string(self) -> Result<String, CommandError> {
379        let bytes = self.bytes()?;
380        String::from_utf8(bytes).map_err(|utf8_error| CommandError {
381            io_error: Some(std::io::Error::new(
382                std::io::ErrorKind::InvalidData,
383                utf8_error,
384            )),
385            exit_status: None,
386        })
387    }
388}
389
390pub struct Command {
391    inner: std::process::Command,
392    stdin_data: Option<Vec<u8>>,
393}
394
395impl Command {
396    pub fn new(value: impl AsRef<OsStr>) -> Self {
397        Command {
398            inner: std::process::Command::new(value),
399            stdin_data: None,
400        }
401    }
402
403    /// Asserts that two commands are equal by comparing their `Debug` representations.
404    ///
405    /// This is useful for testing that a command builder produces the expected command
406    /// without actually executing it.
407    ///
408    /// # Panics
409    ///
410    /// Panics if the `Debug` output of the two commands' inner `std::process::Command` differ.
411    #[cfg(feature = "test-utils")]
412    pub fn test_eq(&self, other: &Self) {
413        assert_eq!(format!("{:?}", self.inner), format!("{:?}", other.inner));
414    }
415
416    pub fn argument(mut self, value: impl AsRef<OsStr>) -> Self {
417        self.inner.arg(value);
418        self
419    }
420
421    pub fn optional_argument(mut self, optional: Option<impl AsRef<OsStr>>) -> Self {
422        if let Some(value) = optional {
423            self.inner.arg(value);
424        }
425        self
426    }
427
428    pub fn arguments<T: AsRef<OsStr>>(mut self, value: impl IntoIterator<Item = T>) -> Self {
429        self.inner.args(value);
430        self
431    }
432
433    pub fn working_directory(mut self, dir: impl AsRef<std::path::Path>) -> Self {
434        self.inner.current_dir(dir);
435        self
436    }
437
438    pub fn env(mut self, key: &EnvVariableName<'_>, val: impl AsRef<OsStr>) -> Self {
439        self.inner.env(key, val);
440        self
441    }
442
443    pub fn envs<I, K, V>(mut self, vars: I) -> Self
444    where
445        I: IntoIterator<Item = (K, V)>,
446        K: AsRef<OsStr>,
447        V: AsRef<OsStr>,
448    {
449        self.inner.envs(vars);
450        self
451    }
452
453    /// Remove an environment variable from the child process.
454    #[must_use]
455    pub fn env_remove(mut self, key: &EnvVariableName<'_>) -> Self {
456        self.inner.env_remove(key);
457        self
458    }
459
460    #[must_use]
461    pub fn stdin_bytes(mut self, data: impl Into<Vec<u8>>) -> Self {
462        self.stdin_data = Some(data.into());
463        self
464    }
465
466    /// Capture stdout from this command.
467    #[must_use]
468    pub fn stdout(self) -> Capture {
469        Capture::new(self, CaptureStream::Stdout)
470    }
471
472    /// Capture stderr from this command.
473    #[must_use]
474    pub fn stderr(self) -> Capture {
475        Capture::new(self, CaptureStream::Stderr)
476    }
477
478    /// Spawn the command as a child process.
479    ///
480    /// Returns a [`Spawn`] builder to configure stdin/stdout/stderr before running.
481    ///
482    /// # Example
483    ///
484    /// ```no_run
485    /// use cmd_proc::{Command, Stdio};
486    /// use std::io::BufRead;
487    ///
488    /// let mut child = Command::new("server")
489    ///     .argument("--port=8080")
490    ///     .spawn()
491    ///     .stdin(Stdio::Piped)
492    ///     .stdout(Stdio::Piped)
493    ///     .stderr(Stdio::Inherit)
494    ///     .run()
495    ///     .unwrap();
496    ///
497    /// // Read a line from stdout
498    /// let line = std::io::BufReader::new(child.stdout().unwrap())
499    ///     .lines()
500    ///     .next()
501    ///     .unwrap()
502    ///     .unwrap();
503    ///
504    /// // Close stdin to signal the child to exit
505    /// drop(child.take_stdin());
506    /// child.wait().unwrap();
507    /// ```
508    #[must_use]
509    pub fn spawn(self) -> Spawn {
510        Spawn::new(self)
511    }
512
513    /// Execute the command and return full output regardless of exit status.
514    ///
515    /// Unlike `stdout()` and `stderr()`, this does not treat non-zero exit as an error.
516    /// Use this when you need to inspect both streams or handle failure cases with stderr.
517    pub fn output(mut self) -> Result<Output, CommandError> {
518        use std::process::Stdio;
519
520        log::debug!("{:#?}", self.inner);
521
522        self.inner.stdout(Stdio::piped());
523        self.inner.stderr(Stdio::piped());
524
525        if self.stdin_data.is_some() {
526            self.inner.stdin(Stdio::piped());
527        }
528
529        let start = std::time::Instant::now();
530
531        let child = self.inner.spawn().map_err(|io_error| CommandError {
532            io_error: Some(io_error),
533            exit_status: None,
534        })?;
535
536        let output = run_and_wait(child, self.stdin_data, start)?;
537
538        Ok(Output {
539            stdout: output.stdout,
540            stderr: output.stderr,
541            status: output.status,
542        })
543    }
544
545    /// Execute the command and return success or an error.
546    pub fn status(mut self) -> Result<(), CommandError> {
547        use std::process::Stdio;
548
549        log::debug!("{:#?}", self.inner);
550
551        if self.stdin_data.is_some() {
552            self.inner.stdin(Stdio::piped());
553        }
554
555        let start = std::time::Instant::now();
556
557        let child = self.inner.spawn().map_err(|io_error| CommandError {
558            io_error: Some(io_error),
559            exit_status: None,
560        })?;
561
562        let exit_status = run_and_wait_status(child, self.stdin_data, start)?;
563
564        if exit_status.success() {
565            Ok(())
566        } else {
567            Err(CommandError {
568                io_error: None,
569                exit_status: Some(exit_status),
570            })
571        }
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn test_stdout_bytes_success() {
581        assert_eq!(
582            Command::new("echo")
583                .argument("hello")
584                .stdout()
585                .bytes()
586                .unwrap(),
587            b"hello\n"
588        );
589    }
590
591    #[test]
592    fn test_stdout_bytes_nonzero_exit() {
593        let error = Command::new("sh")
594            .arguments(["-c", "exit 42"])
595            .stdout()
596            .bytes()
597            .unwrap_err();
598        assert_eq!(
599            error.exit_status.map(|status| status.code()),
600            Some(Some(42))
601        );
602        assert!(error.io_error.is_none());
603    }
604
605    #[test]
606    fn test_stdout_bytes_io_error() {
607        let error = Command::new("./nonexistent").stdout().bytes().unwrap_err();
608        assert!(error.io_error.is_some());
609        assert_eq!(error.io_error.unwrap().kind(), std::io::ErrorKind::NotFound);
610        assert!(error.exit_status.is_none());
611    }
612
613    #[test]
614    fn test_stdout_string_success() {
615        assert_eq!(
616            Command::new("echo")
617                .argument("hello")
618                .stdout()
619                .string()
620                .unwrap(),
621            "hello\n"
622        );
623    }
624
625    #[test]
626    fn test_stderr_bytes_success() {
627        assert_eq!(
628            Command::new("sh")
629                .arguments(["-c", "echo error >&2"])
630                .stderr()
631                .bytes()
632                .unwrap(),
633            b"error\n"
634        );
635    }
636
637    #[test]
638    fn test_stderr_string_success() {
639        assert_eq!(
640            Command::new("sh")
641                .arguments(["-c", "echo error >&2"])
642                .stderr()
643                .string()
644                .unwrap(),
645            "error\n"
646        );
647    }
648
649    #[test]
650    fn test_status_success() {
651        assert!(Command::new("true").status().is_ok());
652    }
653
654    #[test]
655    fn test_status_nonzero_exit() {
656        let error = Command::new("sh")
657            .arguments(["-c", "exit 42"])
658            .status()
659            .unwrap_err();
660        assert_eq!(
661            error.exit_status.map(|status| status.code()),
662            Some(Some(42))
663        );
664        assert!(error.io_error.is_none());
665    }
666
667    #[test]
668    fn test_status_io_error() {
669        let error = Command::new("./nonexistent").status().unwrap_err();
670        assert!(error.io_error.is_some());
671        assert_eq!(error.io_error.unwrap().kind(), std::io::ErrorKind::NotFound);
672        assert!(error.exit_status.is_none());
673    }
674
675    #[test]
676    fn test_env_variable_name_from_static() {
677        const NAME: EnvVariableName<'static> = EnvVariableName::from_static("PATH");
678        assert_eq!(NAME.as_str(), "PATH");
679    }
680
681    #[test]
682    fn test_env_variable_name_parse() {
683        let name: EnvVariableName = "HOME".parse().unwrap();
684        assert_eq!(name.as_str(), "HOME");
685    }
686
687    #[test]
688    fn test_env_variable_name_empty() {
689        let result: Result<EnvVariableName, _> = "".parse();
690        assert!(matches!(result, Err(EnvVariableNameError::Empty)));
691    }
692
693    #[test]
694    fn test_env_variable_name_contains_equals() {
695        let result: Result<EnvVariableName, _> = "FOO=BAR".parse();
696        assert!(matches!(result, Err(EnvVariableNameError::ContainsEquals)));
697    }
698
699    #[test]
700    fn test_env_with_variable() {
701        let name: EnvVariableName = "MY_VAR".parse().unwrap();
702        let output = Command::new("sh")
703            .arguments(["-c", "echo $MY_VAR"])
704            .env(&name, "hello")
705            .stdout()
706            .string()
707            .unwrap();
708        assert_eq!(output, "hello\n");
709    }
710
711    #[test]
712    fn test_stdin_bytes() {
713        let output = Command::new("cat")
714            .stdin_bytes(b"hello world".as_slice())
715            .stdout()
716            .string()
717            .unwrap();
718        assert_eq!(output, "hello world");
719    }
720
721    #[test]
722    fn test_stdin_bytes_vec() {
723        let output = Command::new("cat")
724            .stdin_bytes(vec![104, 105])
725            .stdout()
726            .string()
727            .unwrap();
728        assert_eq!(output, "hi");
729    }
730
731    #[test]
732    fn test_output_success() {
733        let output = Command::new("echo").argument("hello").output().unwrap();
734        assert!(output.success());
735        assert_eq!(output.stdout, b"hello\n");
736        assert!(output.stderr.is_empty());
737    }
738
739    #[test]
740    fn test_output_failure_with_stderr() {
741        let output = Command::new("sh")
742            .arguments(["-c", "echo error >&2; exit 1"])
743            .output()
744            .unwrap();
745        assert!(!output.success());
746        assert_eq!(output.into_stderr_string().unwrap(), "error\n");
747    }
748
749    #[test]
750    fn test_output_io_error() {
751        let error = Command::new("./nonexistent").output().unwrap_err();
752        assert!(error.io_error.is_some());
753        assert_eq!(error.io_error.unwrap().kind(), std::io::ErrorKind::NotFound);
754    }
755
756    #[test]
757    fn test_spawn_with_piped_stdout() {
758        use std::io::Read;
759
760        let mut child = Command::new("echo")
761            .argument("hello")
762            .spawn()
763            .stdout(Stdio::Piped)
764            .run()
765            .unwrap();
766
767        let mut output = String::new();
768        child.stdout().unwrap().read_to_string(&mut output).unwrap();
769        assert_eq!(output, "hello\n");
770
771        let status = child.wait().unwrap();
772        assert!(status.success());
773    }
774
775    #[test]
776    fn test_spawn_with_piped_stdin() {
777        use std::io::Write;
778
779        let mut child = Command::new("cat")
780            .spawn()
781            .stdin(Stdio::Piped)
782            .stdout(Stdio::Piped)
783            .run()
784            .unwrap();
785
786        child.stdin().unwrap().write_all(b"hello").unwrap();
787        drop(child.take_stdin());
788
789        let output = child.wait_with_output().unwrap();
790        assert!(output.success());
791        assert_eq!(output.stdout, b"hello");
792    }
793
794    #[test]
795    fn test_spawn_wait() {
796        let child = Command::new("true").spawn().run().unwrap();
797
798        let status = child.wait().unwrap();
799        assert!(status.success());
800    }
801
802    #[test]
803    fn test_spawn_wait_with_output() {
804        let child = Command::new("sh")
805            .arguments(["-c", "echo out; echo err >&2"])
806            .spawn()
807            .stdout(Stdio::Piped)
808            .stderr(Stdio::Piped)
809            .run()
810            .unwrap();
811
812        let output = child.wait_with_output().unwrap();
813        assert!(output.success());
814        assert_eq!(output.stdout, b"out\n");
815        assert_eq!(output.stderr, b"err\n");
816    }
817
818    #[test]
819    fn test_spawn_io_error() {
820        let error = Command::new("./nonexistent").spawn().run().unwrap_err();
821        assert!(error.io_error.is_some());
822        assert_eq!(error.io_error.unwrap().kind(), std::io::ErrorKind::NotFound);
823    }
824
825    #[test]
826    fn test_spawn_take_handles() {
827        use std::io::{Read, Write};
828
829        let mut child = Command::new("cat")
830            .spawn()
831            .stdin(Stdio::Piped)
832            .stdout(Stdio::Piped)
833            .run()
834            .unwrap();
835
836        let mut stdin = child.take_stdin().unwrap();
837        stdin.write_all(b"test").unwrap();
838        drop(stdin);
839
840        let mut stdout = child.take_stdout().unwrap();
841        let mut output = String::new();
842        stdout.read_to_string(&mut output).unwrap();
843        assert_eq!(output, "test");
844
845        child.wait().unwrap();
846    }
847}