Skip to main content

vcs_runner/
error.rs

1use std::fmt;
2use std::io;
3use std::process::ExitStatus;
4use std::time::Duration;
5
6/// Error type for subprocess execution.
7///
8/// Distinguishes between:
9/// - [`Spawn`](Self::Spawn): infrastructure failure (binary missing, fork failed, etc.)
10/// - [`NonZeroExit`](Self::NonZeroExit): the command ran and reported failure via exit code
11/// - [`Timeout`](Self::Timeout): the command was killed after exceeding its timeout
12///
13/// Marked `#[non_exhaustive]` so future variants can be added without breaking callers.
14/// Match with a wildcard arm to handle unknown variants defensively.
15///
16/// ```no_run
17/// # use std::path::Path;
18/// # use vcs_runner::{run_git, RunError};
19/// let repo = Path::new("/repo");
20/// let maybe_bytes = match run_git(repo, &["show", "maybe-missing-ref"]) {
21///     Ok(output) => Some(output.stdout),
22///     Err(RunError::NonZeroExit { .. }) => None,   // ref not found
23///     Err(e) => return Err(e.into()),              // real failure bubbles up
24/// };
25/// # Ok::<(), anyhow::Error>(())
26/// ```
27#[derive(Debug)]
28#[non_exhaustive]
29pub enum RunError {
30    /// Failed to spawn the child process. The binary may be missing, the
31    /// working directory may not exist, or the OS may have refused the fork.
32    Spawn {
33        program: String,
34        source: io::Error,
35    },
36    /// The child process ran but exited non-zero. For captured commands,
37    /// `stdout` and `stderr` contain what the process wrote before exiting.
38    /// For inherited commands ([`crate::run_cmd_inherited`]), they are empty.
39    NonZeroExit {
40        program: String,
41        args: Vec<String>,
42        status: ExitStatus,
43        stdout: Vec<u8>,
44        stderr: String,
45    },
46    /// The child process was killed after exceeding the caller's timeout.
47    ///
48    /// Any output written to stdout/stderr before the kill signal is included
49    /// when available. The `elapsed` field records how long the process ran.
50    Timeout {
51        program: String,
52        args: Vec<String>,
53        elapsed: Duration,
54        stdout: Vec<u8>,
55        stderr: String,
56    },
57}
58
59impl RunError {
60    /// The program name that failed (e.g., `"git"`, `"jj"`).
61    pub fn program(&self) -> &str {
62        match self {
63            Self::Spawn { program, .. } => program,
64            Self::NonZeroExit { program, .. } => program,
65            Self::Timeout { program, .. } => program,
66        }
67    }
68
69    /// The captured stderr, if any. None for spawn failures.
70    pub fn stderr(&self) -> Option<&str> {
71        match self {
72            Self::NonZeroExit { stderr, .. } => Some(stderr),
73            Self::Timeout { stderr, .. } => Some(stderr),
74            Self::Spawn { .. } => None,
75        }
76    }
77
78    /// The exit status, if the process actually ran to completion.
79    /// None for spawn failures and timeouts.
80    pub fn exit_status(&self) -> Option<ExitStatus> {
81        match self {
82            Self::NonZeroExit { status, .. } => Some(*status),
83            Self::Spawn { .. } | Self::Timeout { .. } => None,
84        }
85    }
86
87    /// Whether this error represents a non-zero exit (the command ran and reported failure).
88    pub fn is_non_zero_exit(&self) -> bool {
89        matches!(self, Self::NonZeroExit { .. })
90    }
91
92    /// Whether this error represents a spawn failure (couldn't start the process).
93    pub fn is_spawn_failure(&self) -> bool {
94        matches!(self, Self::Spawn { .. })
95    }
96
97    /// Whether this error represents a timeout (process killed after exceeding its time budget).
98    pub fn is_timeout(&self) -> bool {
99        matches!(self, Self::Timeout { .. })
100    }
101}
102
103impl fmt::Display for RunError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            Self::Spawn { program, source } => {
107                write!(f, "failed to spawn {program}: {source}")
108            }
109            Self::NonZeroExit {
110                program,
111                args,
112                status,
113                stderr,
114                ..
115            } => {
116                let trimmed = stderr.trim();
117                if trimmed.is_empty() {
118                    write!(f, "{program} {} exited with {status}", args.join(" "))
119                } else {
120                    write!(
121                        f,
122                        "{program} {} exited with {status}: {trimmed}",
123                        args.join(" ")
124                    )
125                }
126            }
127            Self::Timeout {
128                program,
129                args,
130                elapsed,
131                ..
132            } => {
133                write!(
134                    f,
135                    "{program} {} killed after timeout ({:.1}s)",
136                    args.join(" "),
137                    elapsed.as_secs_f64()
138                )
139            }
140        }
141    }
142}
143
144impl std::error::Error for RunError {
145    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
146        match self {
147            Self::Spawn { source, .. } => Some(source),
148            Self::NonZeroExit { .. } | Self::Timeout { .. } => None,
149        }
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn spawn_error() -> RunError {
158        RunError::Spawn {
159            program: "git".into(),
160            source: io::Error::new(io::ErrorKind::NotFound, "not found"),
161        }
162    }
163
164    fn non_zero_exit(stderr: &str) -> RunError {
165        let status = std::process::Command::new("false")
166            .status()
167            .expect("false should be runnable");
168        RunError::NonZeroExit {
169            program: "git".into(),
170            args: vec!["status".into()],
171            status,
172            stdout: Vec::new(),
173            stderr: stderr.to_string(),
174        }
175    }
176
177    fn timeout_error() -> RunError {
178        RunError::Timeout {
179            program: "git".into(),
180            args: vec!["fetch".into()],
181            elapsed: Duration::from_secs(30),
182            stdout: Vec::new(),
183            stderr: "Fetching origin".into(),
184        }
185    }
186
187    #[test]
188    fn program_returns_name() {
189        assert_eq!(spawn_error().program(), "git");
190        assert_eq!(non_zero_exit("").program(), "git");
191        assert_eq!(timeout_error().program(), "git");
192    }
193
194    #[test]
195    fn stderr_only_for_completed_or_timed_out() {
196        assert_eq!(spawn_error().stderr(), None);
197        assert_eq!(non_zero_exit("boom").stderr(), Some("boom"));
198        assert_eq!(timeout_error().stderr(), Some("Fetching origin"));
199    }
200
201    #[test]
202    fn exit_status_only_for_non_zero_exit() {
203        assert!(spawn_error().exit_status().is_none());
204        assert!(non_zero_exit("").exit_status().is_some());
205        assert!(timeout_error().exit_status().is_none());
206    }
207
208    #[test]
209    fn is_non_zero_exit_predicate() {
210        assert!(!spawn_error().is_non_zero_exit());
211        assert!(non_zero_exit("").is_non_zero_exit());
212        assert!(!timeout_error().is_non_zero_exit());
213    }
214
215    #[test]
216    fn is_spawn_failure_predicate() {
217        assert!(spawn_error().is_spawn_failure());
218        assert!(!non_zero_exit("").is_spawn_failure());
219        assert!(!timeout_error().is_spawn_failure());
220    }
221
222    #[test]
223    fn is_timeout_predicate() {
224        assert!(!spawn_error().is_timeout());
225        assert!(!non_zero_exit("").is_timeout());
226        assert!(timeout_error().is_timeout());
227    }
228
229    #[test]
230    fn display_spawn_failure() {
231        let msg = format!("{}", spawn_error());
232        assert!(msg.contains("spawn"));
233        assert!(msg.contains("git"));
234    }
235
236    #[test]
237    fn display_non_zero_exit_with_stderr() {
238        let msg = format!("{}", non_zero_exit("something broke"));
239        assert!(msg.contains("git status"));
240        assert!(msg.contains("something broke"));
241    }
242
243    #[test]
244    fn display_timeout() {
245        let msg = format!("{}", timeout_error());
246        assert!(msg.contains("git fetch"));
247        assert!(msg.contains("timeout"));
248        assert!(msg.contains("30"));
249    }
250
251    #[test]
252    fn error_source_for_spawn() {
253        use std::error::Error;
254        assert!(spawn_error().source().is_some());
255    }
256
257    #[test]
258    fn error_source_none_for_non_spawn() {
259        use std::error::Error;
260        assert!(non_zero_exit("").source().is_none());
261        assert!(timeout_error().source().is_none());
262    }
263
264    #[test]
265    fn wraps_into_anyhow() {
266        let err = spawn_error();
267        let _: anyhow::Error = err.into();
268    }
269}