1use std::fmt;
2use std::io;
3use std::process::ExitStatus;
4use std::time::Duration;
5
6#[derive(Debug)]
28#[non_exhaustive]
29pub enum RunError {
30 Spawn {
33 program: String,
34 source: io::Error,
35 },
36 NonZeroExit {
40 program: String,
41 args: Vec<String>,
42 status: ExitStatus,
43 stdout: Vec<u8>,
44 stderr: String,
45 },
46 Timeout {
51 program: String,
52 args: Vec<String>,
53 elapsed: Duration,
54 stdout: Vec<u8>,
55 stderr: String,
56 },
57}
58
59impl RunError {
60 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 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 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 pub fn is_non_zero_exit(&self) -> bool {
89 matches!(self, Self::NonZeroExit { .. })
90 }
91
92 pub fn is_spawn_failure(&self) -> bool {
94 matches!(self, Self::Spawn { .. })
95 }
96
97 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}