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_capture"
113 | "commit_requires_git_overlay"
114 | "commit_capture_required"
115 | "git_repair_requires_adoption"
116 | "git_repair_requires_import"
117 | "dirty_worktree"
118 | "state_corrupted"
119 | "state_not_found"
120 | "conflict_not_found"
121 | "no_merge_in_progress"
122 | "operation_not_in_progress"
123 | "json_unsupported"
124 | "json_compact_unsupported"
125 | "operator_blocked"
128 | "merge_eligibility_blocked" => Some(Self::DataErr),
130 "capture_out_of_space" => Some(Self::IoErr),
134 _ => None,
135 }
136 }
137
138 pub fn is_quiet_outcome(err: &anyhow::Error) -> bool {
141 err.chain().any(|cause| cause.is::<OutcomeExit>())
142 }
143
144 pub fn from_error(err: &anyhow::Error) -> Self {
148 for cause in err.chain() {
149 if let Some(outcome) = cause.downcast_ref::<OutcomeExit>() {
152 return outcome.code();
153 }
154 if let Some(advice) = cause.downcast_ref::<crate::cli::commands::RecoveryAdvice>()
159 && let Some(code) = Self::for_advice_kind(advice.kind)
160 {
161 return code;
162 }
163 if let Some(heddle_err) = cause.downcast_ref::<objects::error::HeddleError>() {
164 match heddle_err {
165 objects::error::HeddleError::Recovery(details) => {
166 if let Some(code) = Self::for_advice_kind(details.kind) {
167 return code;
168 }
169 }
170 objects::error::HeddleError::RepositoryNotFound(_) => return Self::Config,
173 objects::error::HeddleError::RepositoryFormatTooNew { .. }
174 | objects::error::HeddleError::RepositoryFormatMigrationRequired { .. }
175 | objects::error::HeddleError::StorageFormatTooNew { .. }
176 | objects::error::HeddleError::StorageFormatMigrationRequired { .. } => {
177 return Self::DataErr;
178 }
179 objects::error::HeddleError::StateNotFound(_)
180 | objects::error::HeddleError::NoMergeInProgress
181 | objects::error::HeddleError::ConfigInvalidValue { .. } => {
182 return Self::DataErr;
183 }
184 objects::error::HeddleError::Config(_) => return Self::Config,
185 objects::error::HeddleError::Lock(_) => return Self::TempFail,
186 objects::error::HeddleError::Serialization(_) => return Self::DataErr,
190 _ => {}
191 }
192 }
193 if let Some(remote_err) = cause.downcast_ref::<crate::remote::RemoteError>()
194 && matches!(
195 remote_err,
196 crate::remote::RemoteError::NotFound(_)
197 | crate::remote::RemoteError::NoDefaultRemote
198 )
199 {
200 return Self::Config;
201 }
202 if let Some(protocol) = cause.downcast_ref::<wire::ProtocolError>() {
203 if let Some(typed) =
204 crate::hosted_failure::HostedFailureDetail::from_protocol_error(protocol)
205 && let Some(code) = typed.exit_code()
206 {
207 return code;
208 }
209 return match protocol {
210 wire::ProtocolError::RemoteFailure { code, .. } => {
211 crate::hosted_failure::exit_code_for_remote(*code)
212 }
213 wire::ProtocolError::AuthorizationFailed(_)
214 | wire::ProtocolError::AuthenticationFailed(_) => Self::NoPerm,
215 wire::ProtocolError::ObjectNotFound(_) => Self::Config,
216 wire::ProtocolError::InvalidState(_)
217 | wire::ProtocolError::AlreadyExists(_)
218 | wire::ProtocolError::Serialization(_)
219 | wire::ProtocolError::MessageTooLarge { .. }
220 | wire::ProtocolError::InvalidMessageType(_)
221 | wire::ProtocolError::VersionMismatch { .. }
222 | wire::ProtocolError::CapabilityNotSupported(_) => Self::Protocol,
223 wire::ProtocolError::Io(io) => match io.kind() {
224 IoErrorKind::TimedOut
225 | IoErrorKind::ConnectionRefused
226 | IoErrorKind::ConnectionAborted
227 | IoErrorKind::ConnectionReset
228 | IoErrorKind::Interrupted => Self::TempFail,
229 IoErrorKind::PermissionDenied => Self::NoPerm,
230 _ => Self::IoErr,
231 },
232 wire::ProtocolError::Remote(_) | wire::ProtocolError::LockError(_) => {
233 Self::IoErr
234 }
235 };
236 }
237 if let Some(io) = cause.downcast_ref::<std::io::Error>() {
238 return match io.kind() {
239 IoErrorKind::PermissionDenied => Self::NoPerm,
240 IoErrorKind::TimedOut
241 | IoErrorKind::ConnectionRefused
242 | IoErrorKind::ConnectionAborted
243 | IoErrorKind::ConnectionReset
244 | IoErrorKind::Interrupted => Self::TempFail,
245 IoErrorKind::NotFound | IoErrorKind::AlreadyExists => Self::CantCreat,
246 _ => Self::IoErr,
247 };
248 }
249 if cause.is::<serde_json::Error>() || cause.is::<toml::de::Error>() {
250 return Self::DataErr;
251 }
252 }
253
254 Self::IoErr
255 }
256
257 pub fn as_u8(self) -> u8 {
258 self as u8
259 }
260}
261
262impl From<HeddleExitCode> for i32 {
263 fn from(code: HeddleExitCode) -> Self {
264 code as i32
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn io_permission_denied_maps_to_noperm() {
274 let err: anyhow::Error =
275 std::io::Error::new(IoErrorKind::PermissionDenied, "denied").into();
276 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::NoPerm);
277 }
278
279 #[test]
280 fn typed_repository_lock_failure_maps_to_tempfail() {
281 let err = objects::error::HeddleError::Lock(objects::lock::LockError::Acquire(
282 std::io::Error::new(IoErrorKind::WouldBlock, "contended"),
283 ));
284 assert_eq!(
285 HeddleExitCode::from_error(&anyhow::Error::new(err)),
286 HeddleExitCode::TempFail
287 );
288 }
289
290 #[test]
291 fn io_timed_out_is_retry_safe() {
292 let err: anyhow::Error = std::io::Error::new(IoErrorKind::TimedOut, "slow").into();
293 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::TempFail);
294 }
295
296 #[test]
297 fn config_parse_preserves_toml_source_as_data_err() {
298 let toml_err = toml::from_str::<toml::Value>("= nope").unwrap_err();
303 let err: anyhow::Error = objects::error::HeddleError::ConfigParse {
304 path: std::path::PathBuf::from("/tmp/config.toml"),
305 source: toml_err,
306 }
307 .into();
308 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
309 }
310
311 #[test]
312 fn serde_json_is_data_err() {
313 let err: anyhow::Error = serde_json::from_str::<serde_json::Value>("{")
314 .unwrap_err()
315 .into();
316 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
317 }
318
319 #[test]
320 fn remote_error_no_default_remote_is_config() {
321 let err = anyhow::anyhow!(crate::remote::RemoteError::NoDefaultRemote);
322 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
323 }
324
325 #[test]
326 fn heddle_config_error_is_config() {
327 let err: anyhow::Error =
328 objects::error::HeddleError::Config("workspace config invalid".to_string()).into();
329 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
330 }
331
332 #[test]
333 fn remote_not_configured_advice_is_config() {
334 let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::remote_not_configured(
338 "push"
339 ));
340 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
341 }
342
343 #[test]
344 fn nothing_to_capture_advice_is_data_err() {
345 let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
348 "nothing_to_capture",
349 "nothing to capture",
350 "hint",
351 "unsafe",
352 "would change",
353 "preserved",
354 "heddle status",
355 vec!["heddle status".to_string()],
356 );
357 let err = anyhow::anyhow!(advice);
358 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
359 }
360
361 #[test]
362 fn fsck_authority_refusal_is_data_err() {
363 let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
366 "git_repair_requires_adoption",
367 "Git owns source history in this repository",
368 "hint",
369 "unsafe",
370 "would change",
371 "preserved",
372 "heddle status",
373 vec!["heddle status".to_string()],
374 );
375 let err = anyhow::anyhow!(advice);
376 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
377 }
378
379 #[test]
380 fn repository_not_found_recovery_details_are_config() {
381 let err: anyhow::Error = objects::error::HeddleError::recovery(
382 objects::RecoveryDetails::repository_not_found(std::path::Path::new("/tmp/whatever")),
383 )
384 .into();
385 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
386 }
387
388 #[test]
389 fn repository_not_found_typed_variant_is_config() {
390 let err: anyhow::Error = objects::error::HeddleError::RepositoryNotFound(
393 std::path::PathBuf::from("/tmp/whatever"),
394 )
395 .into();
396 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
397 }
398
399 #[test]
400 fn serialization_error_typed_variant_is_data_err() {
401 let err: anyhow::Error = objects::error::HeddleError::Serialization(
404 "wrong msgpack marker FixArray(0)".to_string(),
405 )
406 .into();
407 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
408 }
409
410 #[test]
411 fn state_not_found_typed_variant_is_data_err() {
412 let err: anyhow::Error = objects::error::HeddleError::StateNotFound(
413 objects::object::StateId::from_bytes([3; 32]),
414 )
415 .into();
416 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
417 }
418
419 #[test]
420 fn invalid_config_value_typed_variant_is_data_err() {
421 let err: anyhow::Error = objects::error::HeddleError::ConfigInvalidValue {
422 path: std::path::PathBuf::from("/tmp/config.toml"),
423 key: "output.format".to_string(),
424 value: "auto".to_string(),
425 valid_values: vec!["'text'".to_string(), "'json'".to_string()],
426 }
427 .into();
428 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
429 }
430
431 #[test]
432 fn repository_format_migration_required_is_data_err() {
433 let err: anyhow::Error = objects::error::HeddleError::RepositoryFormatMigrationRequired {
434 path: std::path::PathBuf::from("/tmp/config.toml"),
435 found: 2,
436 required: 3,
437 }
438 .into();
439 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
440 }
441
442 #[test]
443 fn storage_format_migration_required_is_data_err() {
444 let err: anyhow::Error = objects::error::HeddleError::StorageFormatMigrationRequired {
445 storage: "packed oplog container".to_string(),
446 found: 2,
447 required: 4,
448 }
449 .into();
450 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
451 }
452
453 #[test]
454 fn no_merge_in_progress_typed_variant_is_data_err() {
455 let err: anyhow::Error = objects::error::HeddleError::NoMergeInProgress.into();
456 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
457 }
458
459 #[test]
460 fn recovery_details_kind_uses_advice_exit_code_mapping() {
461 let err: anyhow::Error = objects::error::HeddleError::recovery(
462 objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
463 )
464 .into();
465 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
466 }
467
468 fn advice_with_kind(kind: &'static str) -> anyhow::Error {
472 anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::safety_refusal(
473 kind,
474 "reworded copy that matches no sentinel",
475 "hint",
476 "unsafe",
477 "would change",
478 "preserved",
479 "heddle status",
480 vec!["heddle status".to_string()],
481 ))
482 }
483
484 #[test]
485 fn every_classified_advice_kind_maps_to_its_documented_exit_code() {
486 for (kind, expected) in [
489 ("remote_not_configured", HeddleExitCode::Config),
490 ("remote_not_found", HeddleExitCode::Config),
491 ("repository_not_found", HeddleExitCode::Config),
492 ("nothing_to_capture", HeddleExitCode::DataErr),
493 ("dirty_worktree", HeddleExitCode::DataErr),
494 ("state_corrupted", HeddleExitCode::DataErr),
495 ("state_not_found", HeddleExitCode::DataErr),
496 ("no_merge_in_progress", HeddleExitCode::DataErr),
497 ("operation_not_in_progress", HeddleExitCode::DataErr),
498 ("conflict_not_found", HeddleExitCode::DataErr),
499 ("json_unsupported", HeddleExitCode::DataErr),
500 ("json_compact_unsupported", HeddleExitCode::DataErr),
501 ("operator_blocked", HeddleExitCode::DataErr),
502 ("merge_eligibility_blocked", HeddleExitCode::DataErr),
503 ("capture_out_of_space", HeddleExitCode::IoErr),
504 ] {
505 assert_eq!(
506 HeddleExitCode::from_error(&advice_with_kind(kind)),
507 expected,
508 "advice kind `{kind}` must classify by kind, not message text"
509 );
510 }
511 }
512
513 #[test]
514 fn dirty_worktree_advice_constructor_is_data_err() {
515 let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::dirty_worktree(
518 "merge",
519 vec!["src/lib.rs".to_string()],
520 "repository state was left unchanged",
521 ));
522 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
523 }
524
525 #[test]
526 fn dirty_worktree_recovery_details_are_data_err() {
527 let err: anyhow::Error =
528 objects::error::HeddleError::recovery(objects::RecoveryDetails::safety_refusal(
529 "dirty_worktree",
530 "reworded copy that matches no sentinel",
531 "hint",
532 "unsafe",
533 "would change",
534 "preserved",
535 ))
536 .into();
537 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
538 }
539
540 #[test]
541 fn unsupported_output_advice_is_data_err() {
542 let json = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_unsupported(
546 "shell completion"
547 ));
548 assert_eq!(HeddleExitCode::from_error(&json), HeddleExitCode::DataErr);
549
550 let compact =
551 anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_compact_unsupported("log"));
552 assert_eq!(
553 HeddleExitCode::from_error(&compact),
554 HeddleExitCode::DataErr
555 );
556 }
557
558 #[test]
559 fn state_corrupted_recovery_details_are_data_err() {
560 let err: anyhow::Error = objects::error::HeddleError::recovery(
561 objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
562 )
563 .into();
564 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
565 }
566
567 #[test]
568 fn unclassified_advice_kind_falls_back_to_io_err() {
569 assert_eq!(
572 HeddleExitCode::from_error(&advice_with_kind("hook_veto")),
573 HeddleExitCode::IoErr
574 );
575 }
576
577 #[test]
578 fn outcome_exit_maps_to_its_code() {
579 let err = anyhow::anyhow!(OutcomeExit::data_err());
580 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
581 assert!(HeddleExitCode::is_quiet_outcome(&err));
582 }
583
584 #[test]
585 fn capture_out_of_space_advice_is_io_err() {
586 assert_eq!(
587 HeddleExitCode::from_error(&advice_with_kind("capture_out_of_space")),
588 HeddleExitCode::IoErr
589 );
590 }
591
592 #[test]
593 fn operator_blocked_advice_is_data_err() {
594 assert_eq!(
595 HeddleExitCode::from_error(&advice_with_kind("operator_blocked")),
596 HeddleExitCode::DataErr
597 );
598 assert_eq!(
599 HeddleExitCode::from_error(&advice_with_kind("merge_eligibility_blocked")),
600 HeddleExitCode::DataErr
601 );
602 }
603
604 #[test]
605 fn unknown_falls_back_to_io_err() {
606 let err = anyhow::anyhow!("some unrelated thing went wrong");
607 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::IoErr);
608 }
609
610 #[test]
611 fn u8_repr_matches_sysexits() {
612 assert_eq!(HeddleExitCode::Ok.as_u8(), 0);
613 assert_eq!(HeddleExitCode::Usage.as_u8(), 64);
614 assert_eq!(HeddleExitCode::DataErr.as_u8(), 65);
615 assert_eq!(HeddleExitCode::CantCreat.as_u8(), 73);
616 assert_eq!(HeddleExitCode::IoErr.as_u8(), 74);
617 assert_eq!(HeddleExitCode::TempFail.as_u8(), 75);
618 assert_eq!(HeddleExitCode::Protocol.as_u8(), 76);
619 assert_eq!(HeddleExitCode::NoPerm.as_u8(), 77);
620 assert_eq!(HeddleExitCode::Config.as_u8(), 78);
621 }
622}