Skip to main content

vcs_runner/
error.rs

1use std::fmt;
2use std::io;
3use std::process::ExitStatus;
4
5/// Error type for subprocess execution.
6///
7/// Distinguishes between:
8/// - [`Spawn`](Self::Spawn): infrastructure failure (binary missing, fork failed, etc.)
9/// - [`NonZeroExit`](Self::NonZeroExit): the command ran and reported failure via exit code
10///
11/// Callers that want to treat non-zero exits as legitimate in-band signals
12/// (e.g., `git show <nonexistent-ref>` returning "not found") can pattern-match:
13///
14/// ```no_run
15/// # use std::path::Path;
16/// # use vcs_runner::{run_git, RunError};
17/// let repo = Path::new("/repo");
18/// let maybe_bytes = match run_git(repo, &["show", "maybe-missing-ref"]) {
19///     Ok(output) => Some(output.stdout),
20///     Err(RunError::NonZeroExit { .. }) => None,   // ref not found
21///     Err(e) => return Err(e.into()),              // real failure bubbles up
22/// };
23/// # Ok::<(), anyhow::Error>(())
24/// ```
25#[derive(Debug)]
26pub enum RunError {
27    /// Failed to spawn the child process. The binary may be missing, the
28    /// working directory may not exist, or the OS may have refused the fork.
29    Spawn {
30        program: String,
31        source: io::Error,
32    },
33    /// The child process ran but exited non-zero. For captured commands,
34    /// `stdout` and `stderr` contain what the process wrote before exiting.
35    /// For inherited commands ([`crate::run_cmd_inherited`]), they are empty.
36    NonZeroExit {
37        program: String,
38        args: Vec<String>,
39        status: ExitStatus,
40        stdout: Vec<u8>,
41        stderr: String,
42    },
43}
44
45impl RunError {
46    /// The program name that failed (e.g., `"git"`, `"jj"`).
47    pub fn program(&self) -> &str {
48        match self {
49            Self::Spawn { program, .. } => program,
50            Self::NonZeroExit { program, .. } => program,
51        }
52    }
53
54    /// The captured stderr, if any. Empty for spawn failures and inherited commands.
55    pub fn stderr(&self) -> Option<&str> {
56        match self {
57            Self::NonZeroExit { stderr, .. } => Some(stderr),
58            Self::Spawn { .. } => None,
59        }
60    }
61
62    /// The exit status, if the process actually ran.
63    pub fn exit_status(&self) -> Option<ExitStatus> {
64        match self {
65            Self::NonZeroExit { status, .. } => Some(*status),
66            Self::Spawn { .. } => None,
67        }
68    }
69
70    /// Whether this error represents a non-zero exit (the command ran and reported failure).
71    pub fn is_non_zero_exit(&self) -> bool {
72        matches!(self, Self::NonZeroExit { .. })
73    }
74
75    /// Whether this error represents a spawn failure (couldn't start the process).
76    pub fn is_spawn_failure(&self) -> bool {
77        matches!(self, Self::Spawn { .. })
78    }
79}
80
81impl fmt::Display for RunError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            Self::Spawn { program, source } => {
85                write!(f, "failed to spawn {program}: {source}")
86            }
87            Self::NonZeroExit {
88                program,
89                args,
90                status,
91                stderr,
92                ..
93            } => {
94                let trimmed = stderr.trim();
95                if trimmed.is_empty() {
96                    write!(f, "{program} {} exited with {status}", args.join(" "))
97                } else {
98                    write!(
99                        f,
100                        "{program} {} exited with {status}: {trimmed}",
101                        args.join(" ")
102                    )
103                }
104            }
105        }
106    }
107}
108
109impl std::error::Error for RunError {
110    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
111        match self {
112            Self::Spawn { source, .. } => Some(source),
113            Self::NonZeroExit { .. } => None,
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn spawn_error() -> RunError {
123        RunError::Spawn {
124            program: "git".into(),
125            source: io::Error::new(io::ErrorKind::NotFound, "not found"),
126        }
127    }
128
129    fn non_zero_exit(stderr: &str) -> RunError {
130        // ExitStatus can only be constructed from platform-specific means;
131        // use a command we know exits non-zero to get a real one.
132        let status = std::process::Command::new("false")
133            .status()
134            .expect("false should be runnable");
135        RunError::NonZeroExit {
136            program: "git".into(),
137            args: vec!["status".into()],
138            status,
139            stdout: Vec::new(),
140            stderr: stderr.to_string(),
141        }
142    }
143
144    #[test]
145    fn program_returns_name() {
146        assert_eq!(spawn_error().program(), "git");
147        assert_eq!(non_zero_exit("").program(), "git");
148    }
149
150    #[test]
151    fn stderr_only_for_non_zero_exit() {
152        assert_eq!(spawn_error().stderr(), None);
153        assert_eq!(non_zero_exit("boom").stderr(), Some("boom"));
154    }
155
156    #[test]
157    fn exit_status_only_for_non_zero_exit() {
158        assert!(spawn_error().exit_status().is_none());
159        assert!(non_zero_exit("").exit_status().is_some());
160    }
161
162    #[test]
163    fn is_non_zero_exit_predicate() {
164        assert!(!spawn_error().is_non_zero_exit());
165        assert!(non_zero_exit("").is_non_zero_exit());
166    }
167
168    #[test]
169    fn is_spawn_failure_predicate() {
170        assert!(spawn_error().is_spawn_failure());
171        assert!(!non_zero_exit("").is_spawn_failure());
172    }
173
174    #[test]
175    fn display_spawn_failure() {
176        let msg = format!("{}", spawn_error());
177        assert!(msg.contains("spawn"));
178        assert!(msg.contains("git"));
179        assert!(msg.contains("not found"));
180    }
181
182    #[test]
183    fn display_non_zero_exit_with_stderr() {
184        let msg = format!("{}", non_zero_exit("something broke"));
185        assert!(msg.contains("git status"));
186        assert!(msg.contains("something broke"));
187    }
188
189    #[test]
190    fn display_non_zero_exit_empty_stderr() {
191        let msg = format!("{}", non_zero_exit(""));
192        assert!(msg.contains("git status"));
193        assert!(msg.contains("exited"));
194    }
195
196    #[test]
197    fn error_source_for_spawn() {
198        use std::error::Error;
199        let err = spawn_error();
200        assert!(err.source().is_some());
201    }
202
203    #[test]
204    fn error_source_none_for_exit() {
205        use std::error::Error;
206        let err = non_zero_exit("");
207        assert!(err.source().is_none());
208    }
209
210    #[test]
211    fn wraps_into_anyhow() {
212        // RunError implementing std::error::Error means anyhow can wrap it
213        let err = spawn_error();
214        let _: anyhow::Error = err.into();
215    }
216}