1use std::io::ErrorKind as IoErrorKind;
14
15use clap::error::ErrorKind as ClapErrorKind;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[repr(u8)]
19pub enum HeddleExitCode {
20 Ok = 0,
21 Usage = 64,
23 DataErr = 65,
26 CantCreat = 73,
29 IoErr = 74,
31 TempFail = 75,
34 Protocol = 76,
37 NoPerm = 77,
39 Config = 78,
42}
43
44impl HeddleExitCode {
45 pub fn from_clap(err: &clap::Error) -> Self {
48 match err.kind() {
49 ClapErrorKind::DisplayHelp | ClapErrorKind::DisplayVersion => Self::Ok,
50 _ => Self::Usage,
51 }
52 }
53
54 fn for_advice_kind(kind: &str) -> Option<Self> {
60 match kind {
61 "remote_not_configured" => Some(Self::Config),
64 "nothing_to_commit"
73 | "reconcile_direction_required"
74 | "dirty_worktree"
75 | "state_corrupted"
76 | "conflict_not_found"
77 | "json_unsupported"
78 | "json_compact_unsupported" => Some(Self::DataErr),
79 _ => None,
80 }
81 }
82
83 pub fn from_error(err: &anyhow::Error) -> Self {
87 for cause in err.chain() {
88 if let Some(advice) = cause.downcast_ref::<crate::cli::commands::RecoveryAdvice>()
93 && let Some(code) = Self::for_advice_kind(advice.kind)
94 {
95 return code;
96 }
97 if let Some(heddle_err) = cause.downcast_ref::<objects::error::HeddleError>() {
98 match heddle_err {
99 objects::error::HeddleError::RepositoryNotFound(_) => return Self::Config,
102 objects::error::HeddleError::RepositoryFormatTooNew { .. } => {
103 return Self::DataErr;
104 }
105 objects::error::HeddleError::Serialization(_) => return Self::DataErr,
109 _ => {}
110 }
111 }
112 if let Some(io) = cause.downcast_ref::<std::io::Error>() {
113 return match io.kind() {
114 IoErrorKind::PermissionDenied => Self::NoPerm,
115 IoErrorKind::TimedOut
116 | IoErrorKind::ConnectionRefused
117 | IoErrorKind::ConnectionAborted
118 | IoErrorKind::ConnectionReset
119 | IoErrorKind::Interrupted => Self::TempFail,
120 IoErrorKind::NotFound | IoErrorKind::AlreadyExists => Self::CantCreat,
121 _ => Self::IoErr,
122 };
123 }
124 if let Some(status) = cause.downcast_ref::<tonic::Status>() {
125 use tonic::Code;
126 return match status.code() {
127 Code::Unavailable | Code::DeadlineExceeded | Code::ResourceExhausted => {
128 Self::TempFail
129 }
130 Code::InvalidArgument | Code::FailedPrecondition | Code::OutOfRange => {
131 Self::Protocol
132 }
133 Code::PermissionDenied | Code::Unauthenticated => Self::NoPerm,
134 Code::NotFound => Self::Config,
135 _ => Self::IoErr,
136 };
137 }
138 if cause.is::<serde_json::Error>() || cause.is::<toml::de::Error>() {
139 return Self::DataErr;
140 }
141 }
142
143 let msg = format!("{err:#}");
152 if msg.contains("no upstream configured")
153 || msg.contains("no remote configured")
154 || msg.contains("no default remote configured")
155 || msg.contains("workspace config invalid")
156 || msg.contains("repository not found")
157 {
158 return Self::Config;
159 }
160 if msg.contains("dirty worktree") {
161 return Self::DataErr;
162 }
163
164 Self::IoErr
165 }
166
167 pub fn as_u8(self) -> u8 {
168 self as u8
169 }
170}
171
172impl From<HeddleExitCode> for i32 {
173 fn from(code: HeddleExitCode) -> Self {
174 code as i32
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn io_permission_denied_maps_to_noperm() {
184 let err: anyhow::Error =
185 std::io::Error::new(IoErrorKind::PermissionDenied, "denied").into();
186 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::NoPerm);
187 }
188
189 #[test]
190 fn io_timed_out_is_retry_safe() {
191 let err: anyhow::Error = std::io::Error::new(IoErrorKind::TimedOut, "slow").into();
192 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::TempFail);
193 }
194
195 #[test]
196 fn config_parse_preserves_toml_source_as_data_err() {
197 let toml_err = toml::from_str::<toml::Value>("= nope").unwrap_err();
202 let err: anyhow::Error = objects::error::HeddleError::ConfigParse {
203 path: std::path::PathBuf::from("/tmp/config.toml"),
204 source: toml_err,
205 }
206 .into();
207 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
208 }
209
210 #[test]
211 fn serde_json_is_data_err() {
212 let err: anyhow::Error = serde_json::from_str::<serde_json::Value>("{")
213 .unwrap_err()
214 .into();
215 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
216 }
217
218 #[test]
219 fn no_upstream_string_sentinel_is_config() {
220 let err = anyhow::anyhow!("push refused: no upstream configured for branch 'main'");
221 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
222 }
223
224 #[test]
225 fn no_default_remote_string_sentinel_is_config() {
226 let err = anyhow::anyhow!("remote not found: (no default remote configured)");
231 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
232 }
233
234 #[test]
235 fn remote_not_configured_advice_is_config() {
236 let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::remote_not_configured(
240 "push"
241 ));
242 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
243 }
244
245 #[test]
246 fn nothing_to_commit_advice_is_data_err() {
247 let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
250 "nothing_to_commit",
251 "nothing to commit",
252 "hint",
253 "unsafe",
254 "would change",
255 "preserved",
256 "heddle status",
257 vec!["heddle status".to_string()],
258 );
259 let err = anyhow::anyhow!(advice);
260 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
261 }
262
263 #[test]
264 fn reconcile_direction_required_advice_is_data_err() {
265 let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
268 "reconcile_direction_required",
269 "Refusing to reconcile 'main': choose a local side before applying",
270 "hint",
271 "unsafe",
272 "would change",
273 "preserved",
274 "heddle status",
275 vec!["heddle status".to_string()],
276 );
277 let err = anyhow::anyhow!(advice);
278 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
279 }
280
281 #[test]
282 fn missing_repo_string_sentinel_is_config() {
283 let err = anyhow::anyhow!("repository not found at /tmp/whatever");
284 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
285 }
286
287 #[test]
288 fn repository_not_found_typed_variant_is_config() {
289 let err: anyhow::Error = objects::error::HeddleError::RepositoryNotFound(
292 std::path::PathBuf::from("/tmp/whatever"),
293 )
294 .into();
295 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
296 }
297
298 #[test]
299 fn serialization_error_typed_variant_is_data_err() {
300 let err: anyhow::Error = objects::error::HeddleError::Serialization(
303 "wrong msgpack marker FixArray(0)".to_string(),
304 )
305 .into();
306 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
307 }
308
309 fn advice_with_kind(kind: &'static str) -> anyhow::Error {
313 anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::safety_refusal(
314 kind,
315 "reworded copy that matches no sentinel",
316 "hint",
317 "unsafe",
318 "would change",
319 "preserved",
320 "heddle status",
321 vec!["heddle status".to_string()],
322 ))
323 }
324
325 #[test]
326 fn every_classified_advice_kind_maps_to_its_documented_exit_code() {
327 for (kind, expected) in [
330 ("remote_not_configured", HeddleExitCode::Config),
331 ("nothing_to_commit", HeddleExitCode::DataErr),
332 ("reconcile_direction_required", HeddleExitCode::DataErr),
333 ("dirty_worktree", HeddleExitCode::DataErr),
334 ("state_corrupted", HeddleExitCode::DataErr),
335 ("conflict_not_found", HeddleExitCode::DataErr),
336 ("json_unsupported", HeddleExitCode::DataErr),
337 ("json_compact_unsupported", HeddleExitCode::DataErr),
338 ] {
339 assert_eq!(
340 HeddleExitCode::from_error(&advice_with_kind(kind)),
341 expected,
342 "advice kind `{kind}` must classify by kind, not message text"
343 );
344 }
345 }
346
347 #[test]
348 fn dirty_worktree_advice_constructor_is_data_err() {
349 let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::dirty_worktree(
352 "merge",
353 vec!["src/lib.rs".to_string()],
354 "repository state was left unchanged",
355 ));
356 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
357 }
358
359 #[test]
360 fn dirty_worktree_string_sentinel_is_data_err() {
361 let err =
364 anyhow::anyhow!("dirty worktree would be overwritten by full rematerialize (switch)");
365 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
366 }
367
368 #[test]
369 fn unsupported_output_advice_is_data_err() {
370 let json = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_unsupported(
374 "shell completion"
375 ));
376 assert_eq!(HeddleExitCode::from_error(&json), HeddleExitCode::DataErr);
377
378 let compact =
379 anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_compact_unsupported("log"));
380 assert_eq!(
381 HeddleExitCode::from_error(&compact),
382 HeddleExitCode::DataErr
383 );
384 }
385
386 #[test]
387 fn state_corrupted_advice_is_data_err() {
388 let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::serialization_error(
389 "wrong msgpack marker FixArray(0)"
390 ));
391 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
392 }
393
394 #[test]
395 fn unclassified_advice_kind_falls_back_to_io_err() {
396 assert_eq!(
399 HeddleExitCode::from_error(&advice_with_kind("hook_veto")),
400 HeddleExitCode::IoErr
401 );
402 }
403
404 #[test]
405 fn unknown_falls_back_to_io_err() {
406 let err = anyhow::anyhow!("some unrelated thing went wrong");
407 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::IoErr);
408 }
409
410 #[test]
411 fn u8_repr_matches_sysexits() {
412 assert_eq!(HeddleExitCode::Ok.as_u8(), 0);
413 assert_eq!(HeddleExitCode::Usage.as_u8(), 64);
414 assert_eq!(HeddleExitCode::DataErr.as_u8(), 65);
415 assert_eq!(HeddleExitCode::CantCreat.as_u8(), 73);
416 assert_eq!(HeddleExitCode::IoErr.as_u8(), 74);
417 assert_eq!(HeddleExitCode::TempFail.as_u8(), 75);
418 assert_eq!(HeddleExitCode::Protocol.as_u8(), 76);
419 assert_eq!(HeddleExitCode::NoPerm.as_u8(), 77);
420 assert_eq!(HeddleExitCode::Config.as_u8(), 78);
421 }
422}