1use std::{error::Error, fmt, 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
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct OutcomeExit {
51 code: HeddleExitCode,
52}
53
54impl OutcomeExit {
55 pub const fn new(code: HeddleExitCode) -> Self {
56 Self { code }
57 }
58
59 pub const fn code(self) -> HeddleExitCode {
60 self.code
61 }
62
63 pub const fn data_err() -> Self {
66 Self::new(HeddleExitCode::DataErr)
67 }
68}
69
70impl fmt::Display for OutcomeExit {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 write!(
73 f,
74 "command completed with non-zero status {}",
75 self.code.as_u8()
76 )
77 }
78}
79
80impl Error for OutcomeExit {}
81
82impl HeddleExitCode {
83 pub fn from_clap(err: &clap::Error) -> Self {
86 match err.kind() {
87 ClapErrorKind::DisplayHelp | ClapErrorKind::DisplayVersion => Self::Ok,
88 _ => Self::Usage,
89 }
90 }
91
92 fn for_advice_kind(kind: &str) -> Option<Self> {
98 match kind {
99 "remote_not_configured" | "remote_not_found" | "repository_not_found" => {
102 Some(Self::Config)
103 }
104 "nothing_to_commit"
113 | "reconcile_direction_required"
114 | "git_repair_direction_required"
115 | "dirty_worktree"
116 | "state_corrupted"
117 | "state_not_found"
118 | "conflict_not_found"
119 | "no_merge_in_progress"
120 | "operation_not_in_progress"
121 | "json_unsupported"
122 | "json_compact_unsupported"
123 | "operator_blocked"
126 | "merge_eligibility_blocked" => Some(Self::DataErr),
128 "capture_out_of_space" => Some(Self::IoErr),
132 _ => None,
133 }
134 }
135
136 pub fn is_quiet_outcome(err: &anyhow::Error) -> bool {
139 err.chain().any(|cause| cause.is::<OutcomeExit>())
140 }
141
142 pub fn from_error(err: &anyhow::Error) -> Self {
146 for cause in err.chain() {
147 if let Some(outcome) = cause.downcast_ref::<OutcomeExit>() {
150 return outcome.code();
151 }
152 if let Some(advice) = cause.downcast_ref::<crate::cli::commands::RecoveryAdvice>()
157 && let Some(code) = Self::for_advice_kind(advice.kind)
158 {
159 return code;
160 }
161 if let Some(heddle_err) = cause.downcast_ref::<objects::error::HeddleError>() {
162 match heddle_err {
163 objects::error::HeddleError::Recovery(details) => {
164 if let Some(code) = Self::for_advice_kind(details.kind) {
165 return code;
166 }
167 }
168 objects::error::HeddleError::RepositoryNotFound(_) => return Self::Config,
171 objects::error::HeddleError::RepositoryFormatTooNew { .. } => {
172 return Self::DataErr;
173 }
174 objects::error::HeddleError::StateNotFound(_)
175 | objects::error::HeddleError::NoMergeInProgress
176 | objects::error::HeddleError::ConfigInvalidValue { .. } => {
177 return Self::DataErr;
178 }
179 objects::error::HeddleError::Config(_) => return Self::Config,
180 objects::error::HeddleError::Serialization(_) => return Self::DataErr,
184 _ => {}
185 }
186 }
187 if let Some(remote_err) = cause.downcast_ref::<crate::remote::RemoteError>()
188 && matches!(
189 remote_err,
190 crate::remote::RemoteError::NotFound(_)
191 | crate::remote::RemoteError::NoDefaultRemote
192 )
193 {
194 return Self::Config;
195 }
196 if let Some(io) = cause.downcast_ref::<std::io::Error>() {
197 return match io.kind() {
198 IoErrorKind::PermissionDenied => Self::NoPerm,
199 IoErrorKind::TimedOut
200 | IoErrorKind::ConnectionRefused
201 | IoErrorKind::ConnectionAborted
202 | IoErrorKind::ConnectionReset
203 | IoErrorKind::Interrupted => Self::TempFail,
204 IoErrorKind::NotFound | IoErrorKind::AlreadyExists => Self::CantCreat,
205 _ => Self::IoErr,
206 };
207 }
208 if let Some(status) = cause.downcast_ref::<tonic::Status>() {
209 use tonic::Code;
210 return match status.code() {
211 Code::Unavailable | Code::DeadlineExceeded | Code::ResourceExhausted => {
212 Self::TempFail
213 }
214 Code::InvalidArgument | Code::FailedPrecondition | Code::OutOfRange => {
215 Self::Protocol
216 }
217 Code::PermissionDenied | Code::Unauthenticated => Self::NoPerm,
218 Code::NotFound => Self::Config,
219 _ => Self::IoErr,
220 };
221 }
222 if cause.is::<serde_json::Error>() || cause.is::<toml::de::Error>() {
223 return Self::DataErr;
224 }
225 }
226
227 Self::IoErr
228 }
229
230 pub fn as_u8(self) -> u8 {
231 self as u8
232 }
233}
234
235impl From<HeddleExitCode> for i32 {
236 fn from(code: HeddleExitCode) -> Self {
237 code as i32
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn io_permission_denied_maps_to_noperm() {
247 let err: anyhow::Error =
248 std::io::Error::new(IoErrorKind::PermissionDenied, "denied").into();
249 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::NoPerm);
250 }
251
252 #[test]
253 fn io_timed_out_is_retry_safe() {
254 let err: anyhow::Error = std::io::Error::new(IoErrorKind::TimedOut, "slow").into();
255 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::TempFail);
256 }
257
258 #[test]
259 fn config_parse_preserves_toml_source_as_data_err() {
260 let toml_err = toml::from_str::<toml::Value>("= nope").unwrap_err();
265 let err: anyhow::Error = objects::error::HeddleError::ConfigParse {
266 path: std::path::PathBuf::from("/tmp/config.toml"),
267 source: toml_err,
268 }
269 .into();
270 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
271 }
272
273 #[test]
274 fn serde_json_is_data_err() {
275 let err: anyhow::Error = serde_json::from_str::<serde_json::Value>("{")
276 .unwrap_err()
277 .into();
278 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
279 }
280
281 #[test]
282 fn remote_error_no_default_remote_is_config() {
283 let err = anyhow::anyhow!(crate::remote::RemoteError::NoDefaultRemote);
284 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
285 }
286
287 #[test]
288 fn heddle_config_error_is_config() {
289 let err: anyhow::Error =
290 objects::error::HeddleError::Config("workspace config invalid".to_string()).into();
291 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
292 }
293
294 #[test]
295 fn remote_not_configured_advice_is_config() {
296 let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::remote_not_configured(
300 "push"
301 ));
302 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
303 }
304
305 #[test]
306 fn nothing_to_commit_advice_is_data_err() {
307 let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
310 "nothing_to_commit",
311 "nothing to commit",
312 "hint",
313 "unsafe",
314 "would change",
315 "preserved",
316 "heddle status",
317 vec!["heddle status".to_string()],
318 );
319 let err = anyhow::anyhow!(advice);
320 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
321 }
322
323 #[test]
324 fn reconcile_direction_required_advice_is_data_err() {
325 let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
328 "reconcile_direction_required",
329 "Refusing to reconcile 'main': choose a local side before applying",
330 "hint",
331 "unsafe",
332 "would change",
333 "preserved",
334 "heddle status",
335 vec!["heddle status".to_string()],
336 );
337 let err = anyhow::anyhow!(advice);
338 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
339 }
340
341 #[test]
342 fn repository_not_found_recovery_details_are_config() {
343 let err: anyhow::Error = objects::error::HeddleError::recovery(
344 objects::RecoveryDetails::repository_not_found(std::path::Path::new("/tmp/whatever")),
345 )
346 .into();
347 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
348 }
349
350 #[test]
351 fn repository_not_found_typed_variant_is_config() {
352 let err: anyhow::Error = objects::error::HeddleError::RepositoryNotFound(
355 std::path::PathBuf::from("/tmp/whatever"),
356 )
357 .into();
358 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
359 }
360
361 #[test]
362 fn serialization_error_typed_variant_is_data_err() {
363 let err: anyhow::Error = objects::error::HeddleError::Serialization(
366 "wrong msgpack marker FixArray(0)".to_string(),
367 )
368 .into();
369 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
370 }
371
372 #[test]
373 fn state_not_found_typed_variant_is_data_err() {
374 let err: anyhow::Error =
375 objects::error::HeddleError::StateNotFound(objects::object::ChangeId::generate())
376 .into();
377 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
378 }
379
380 #[test]
381 fn invalid_config_value_typed_variant_is_data_err() {
382 let err: anyhow::Error = objects::error::HeddleError::ConfigInvalidValue {
383 path: std::path::PathBuf::from("/tmp/config.toml"),
384 key: "output.format".to_string(),
385 value: "auto".to_string(),
386 valid_values: vec!["'text'".to_string(), "'json'".to_string()],
387 }
388 .into();
389 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
390 }
391
392 #[test]
393 fn no_merge_in_progress_typed_variant_is_data_err() {
394 let err: anyhow::Error = objects::error::HeddleError::NoMergeInProgress.into();
395 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
396 }
397
398 #[test]
399 fn recovery_details_kind_uses_advice_exit_code_mapping() {
400 let err: anyhow::Error = objects::error::HeddleError::recovery(
401 objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
402 )
403 .into();
404 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
405 }
406
407 fn advice_with_kind(kind: &'static str) -> anyhow::Error {
411 anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::safety_refusal(
412 kind,
413 "reworded copy that matches no sentinel",
414 "hint",
415 "unsafe",
416 "would change",
417 "preserved",
418 "heddle status",
419 vec!["heddle status".to_string()],
420 ))
421 }
422
423 #[test]
424 fn every_classified_advice_kind_maps_to_its_documented_exit_code() {
425 for (kind, expected) in [
428 ("remote_not_configured", HeddleExitCode::Config),
429 ("remote_not_found", HeddleExitCode::Config),
430 ("repository_not_found", HeddleExitCode::Config),
431 ("nothing_to_commit", HeddleExitCode::DataErr),
432 ("reconcile_direction_required", HeddleExitCode::DataErr),
433 ("git_repair_direction_required", HeddleExitCode::DataErr),
434 ("dirty_worktree", HeddleExitCode::DataErr),
435 ("state_corrupted", HeddleExitCode::DataErr),
436 ("state_not_found", HeddleExitCode::DataErr),
437 ("no_merge_in_progress", HeddleExitCode::DataErr),
438 ("operation_not_in_progress", HeddleExitCode::DataErr),
439 ("conflict_not_found", HeddleExitCode::DataErr),
440 ("json_unsupported", HeddleExitCode::DataErr),
441 ("json_compact_unsupported", HeddleExitCode::DataErr),
442 ("operator_blocked", HeddleExitCode::DataErr),
443 ("merge_eligibility_blocked", HeddleExitCode::DataErr),
444 ("capture_out_of_space", HeddleExitCode::IoErr),
445 ] {
446 assert_eq!(
447 HeddleExitCode::from_error(&advice_with_kind(kind)),
448 expected,
449 "advice kind `{kind}` must classify by kind, not message text"
450 );
451 }
452 }
453
454 #[test]
455 fn dirty_worktree_advice_constructor_is_data_err() {
456 let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::dirty_worktree(
459 "merge",
460 vec!["src/lib.rs".to_string()],
461 "repository state was left unchanged",
462 ));
463 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
464 }
465
466 #[test]
467 fn dirty_worktree_recovery_details_are_data_err() {
468 let err: anyhow::Error =
469 objects::error::HeddleError::recovery(objects::RecoveryDetails::safety_refusal(
470 "dirty_worktree",
471 "reworded copy that matches no sentinel",
472 "hint",
473 "unsafe",
474 "would change",
475 "preserved",
476 ))
477 .into();
478 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
479 }
480
481 #[test]
482 fn unsupported_output_advice_is_data_err() {
483 let json = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_unsupported(
487 "shell completion"
488 ));
489 assert_eq!(HeddleExitCode::from_error(&json), HeddleExitCode::DataErr);
490
491 let compact =
492 anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_compact_unsupported("log"));
493 assert_eq!(
494 HeddleExitCode::from_error(&compact),
495 HeddleExitCode::DataErr
496 );
497 }
498
499 #[test]
500 fn state_corrupted_recovery_details_are_data_err() {
501 let err: anyhow::Error = objects::error::HeddleError::recovery(
502 objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
503 )
504 .into();
505 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
506 }
507
508 #[test]
509 fn unclassified_advice_kind_falls_back_to_io_err() {
510 assert_eq!(
513 HeddleExitCode::from_error(&advice_with_kind("hook_veto")),
514 HeddleExitCode::IoErr
515 );
516 }
517
518 #[test]
519 fn outcome_exit_maps_to_its_code() {
520 let err = anyhow::anyhow!(OutcomeExit::data_err());
521 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
522 assert!(HeddleExitCode::is_quiet_outcome(&err));
523 }
524
525 #[test]
526 fn capture_out_of_space_advice_is_io_err() {
527 assert_eq!(
528 HeddleExitCode::from_error(&advice_with_kind("capture_out_of_space")),
529 HeddleExitCode::IoErr
530 );
531 }
532
533 #[test]
534 fn operator_blocked_advice_is_data_err() {
535 assert_eq!(
536 HeddleExitCode::from_error(&advice_with_kind("operator_blocked")),
537 HeddleExitCode::DataErr
538 );
539 assert_eq!(
540 HeddleExitCode::from_error(&advice_with_kind("merge_eligibility_blocked")),
541 HeddleExitCode::DataErr
542 );
543 }
544
545 #[test]
546 fn unknown_falls_back_to_io_err() {
547 let err = anyhow::anyhow!("some unrelated thing went wrong");
548 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::IoErr);
549 }
550
551 #[test]
552 fn u8_repr_matches_sysexits() {
553 assert_eq!(HeddleExitCode::Ok.as_u8(), 0);
554 assert_eq!(HeddleExitCode::Usage.as_u8(), 64);
555 assert_eq!(HeddleExitCode::DataErr.as_u8(), 65);
556 assert_eq!(HeddleExitCode::CantCreat.as_u8(), 73);
557 assert_eq!(HeddleExitCode::IoErr.as_u8(), 74);
558 assert_eq!(HeddleExitCode::TempFail.as_u8(), 75);
559 assert_eq!(HeddleExitCode::Protocol.as_u8(), 76);
560 assert_eq!(HeddleExitCode::NoPerm.as_u8(), 77);
561 assert_eq!(HeddleExitCode::Config.as_u8(), 78);
562 }
563}