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(io) = cause.downcast_ref::<std::io::Error>() {
203 return match io.kind() {
204 IoErrorKind::PermissionDenied => Self::NoPerm,
205 IoErrorKind::TimedOut
206 | IoErrorKind::ConnectionRefused
207 | IoErrorKind::ConnectionAborted
208 | IoErrorKind::ConnectionReset
209 | IoErrorKind::Interrupted => Self::TempFail,
210 IoErrorKind::NotFound | IoErrorKind::AlreadyExists => Self::CantCreat,
211 _ => Self::IoErr,
212 };
213 }
214 if let Some(status) = cause.downcast_ref::<tonic::Status>() {
215 use tonic::Code;
216 if let Some(typed) = crate::hosted_typed_error::HostedTypedError::from_status(status)
221 && let Some(code) = typed.exit_code()
222 {
223 return code;
224 }
225 return match status.code() {
226 Code::Unavailable | Code::DeadlineExceeded | Code::ResourceExhausted => {
227 Self::TempFail
228 }
229 Code::InvalidArgument | Code::FailedPrecondition | Code::OutOfRange => {
230 Self::Protocol
231 }
232 Code::PermissionDenied | Code::Unauthenticated => Self::NoPerm,
233 Code::NotFound => Self::Config,
234 _ => Self::IoErr,
235 };
236 }
237 if cause.is::<serde_json::Error>() || cause.is::<toml::de::Error>() {
238 return Self::DataErr;
239 }
240 }
241
242 Self::IoErr
243 }
244
245 pub fn as_u8(self) -> u8 {
246 self as u8
247 }
248}
249
250impl From<HeddleExitCode> for i32 {
251 fn from(code: HeddleExitCode) -> Self {
252 code as i32
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn io_permission_denied_maps_to_noperm() {
262 let err: anyhow::Error =
263 std::io::Error::new(IoErrorKind::PermissionDenied, "denied").into();
264 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::NoPerm);
265 }
266
267 #[test]
268 fn typed_repository_lock_failure_maps_to_tempfail() {
269 let err = objects::error::HeddleError::Lock(objects::lock::LockError::Acquire(
270 std::io::Error::new(IoErrorKind::WouldBlock, "contended"),
271 ));
272 assert_eq!(
273 HeddleExitCode::from_error(&anyhow::Error::new(err)),
274 HeddleExitCode::TempFail
275 );
276 }
277
278 #[test]
279 fn io_timed_out_is_retry_safe() {
280 let err: anyhow::Error = std::io::Error::new(IoErrorKind::TimedOut, "slow").into();
281 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::TempFail);
282 }
283
284 #[test]
285 fn config_parse_preserves_toml_source_as_data_err() {
286 let toml_err = toml::from_str::<toml::Value>("= nope").unwrap_err();
291 let err: anyhow::Error = objects::error::HeddleError::ConfigParse {
292 path: std::path::PathBuf::from("/tmp/config.toml"),
293 source: toml_err,
294 }
295 .into();
296 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
297 }
298
299 #[test]
300 fn serde_json_is_data_err() {
301 let err: anyhow::Error = serde_json::from_str::<serde_json::Value>("{")
302 .unwrap_err()
303 .into();
304 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
305 }
306
307 #[test]
308 fn remote_error_no_default_remote_is_config() {
309 let err = anyhow::anyhow!(crate::remote::RemoteError::NoDefaultRemote);
310 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
311 }
312
313 #[test]
314 fn heddle_config_error_is_config() {
315 let err: anyhow::Error =
316 objects::error::HeddleError::Config("workspace config invalid".to_string()).into();
317 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
318 }
319
320 #[test]
321 fn remote_not_configured_advice_is_config() {
322 let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::remote_not_configured(
326 "push"
327 ));
328 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
329 }
330
331 #[test]
332 fn nothing_to_capture_advice_is_data_err() {
333 let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
336 "nothing_to_capture",
337 "nothing to capture",
338 "hint",
339 "unsafe",
340 "would change",
341 "preserved",
342 "heddle status",
343 vec!["heddle status".to_string()],
344 );
345 let err = anyhow::anyhow!(advice);
346 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
347 }
348
349 #[test]
350 fn fsck_authority_refusal_is_data_err() {
351 let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
354 "git_repair_requires_adoption",
355 "Git owns source history in this repository",
356 "hint",
357 "unsafe",
358 "would change",
359 "preserved",
360 "heddle status",
361 vec!["heddle status".to_string()],
362 );
363 let err = anyhow::anyhow!(advice);
364 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
365 }
366
367 #[test]
368 fn repository_not_found_recovery_details_are_config() {
369 let err: anyhow::Error = objects::error::HeddleError::recovery(
370 objects::RecoveryDetails::repository_not_found(std::path::Path::new("/tmp/whatever")),
371 )
372 .into();
373 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
374 }
375
376 #[test]
377 fn repository_not_found_typed_variant_is_config() {
378 let err: anyhow::Error = objects::error::HeddleError::RepositoryNotFound(
381 std::path::PathBuf::from("/tmp/whatever"),
382 )
383 .into();
384 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
385 }
386
387 #[test]
388 fn serialization_error_typed_variant_is_data_err() {
389 let err: anyhow::Error = objects::error::HeddleError::Serialization(
392 "wrong msgpack marker FixArray(0)".to_string(),
393 )
394 .into();
395 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
396 }
397
398 #[test]
399 fn state_not_found_typed_variant_is_data_err() {
400 let err: anyhow::Error = objects::error::HeddleError::StateNotFound(
401 objects::object::StateId::from_bytes([3; 32]),
402 )
403 .into();
404 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
405 }
406
407 #[test]
408 fn invalid_config_value_typed_variant_is_data_err() {
409 let err: anyhow::Error = objects::error::HeddleError::ConfigInvalidValue {
410 path: std::path::PathBuf::from("/tmp/config.toml"),
411 key: "output.format".to_string(),
412 value: "auto".to_string(),
413 valid_values: vec!["'text'".to_string(), "'json'".to_string()],
414 }
415 .into();
416 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
417 }
418
419 #[test]
420 fn repository_format_migration_required_is_data_err() {
421 let err: anyhow::Error = objects::error::HeddleError::RepositoryFormatMigrationRequired {
422 path: std::path::PathBuf::from("/tmp/config.toml"),
423 found: 2,
424 required: 3,
425 }
426 .into();
427 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
428 }
429
430 #[test]
431 fn storage_format_migration_required_is_data_err() {
432 let err: anyhow::Error = objects::error::HeddleError::StorageFormatMigrationRequired {
433 storage: "packed oplog container".to_string(),
434 found: 2,
435 required: 4,
436 }
437 .into();
438 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
439 }
440
441 #[test]
442 fn no_merge_in_progress_typed_variant_is_data_err() {
443 let err: anyhow::Error = objects::error::HeddleError::NoMergeInProgress.into();
444 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
445 }
446
447 #[test]
448 fn recovery_details_kind_uses_advice_exit_code_mapping() {
449 let err: anyhow::Error = objects::error::HeddleError::recovery(
450 objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
451 )
452 .into();
453 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
454 }
455
456 fn advice_with_kind(kind: &'static str) -> anyhow::Error {
460 anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::safety_refusal(
461 kind,
462 "reworded copy that matches no sentinel",
463 "hint",
464 "unsafe",
465 "would change",
466 "preserved",
467 "heddle status",
468 vec!["heddle status".to_string()],
469 ))
470 }
471
472 #[test]
473 fn every_classified_advice_kind_maps_to_its_documented_exit_code() {
474 for (kind, expected) in [
477 ("remote_not_configured", HeddleExitCode::Config),
478 ("remote_not_found", HeddleExitCode::Config),
479 ("repository_not_found", HeddleExitCode::Config),
480 ("nothing_to_capture", HeddleExitCode::DataErr),
481 ("dirty_worktree", HeddleExitCode::DataErr),
482 ("state_corrupted", HeddleExitCode::DataErr),
483 ("state_not_found", HeddleExitCode::DataErr),
484 ("no_merge_in_progress", HeddleExitCode::DataErr),
485 ("operation_not_in_progress", HeddleExitCode::DataErr),
486 ("conflict_not_found", HeddleExitCode::DataErr),
487 ("json_unsupported", HeddleExitCode::DataErr),
488 ("json_compact_unsupported", HeddleExitCode::DataErr),
489 ("operator_blocked", HeddleExitCode::DataErr),
490 ("merge_eligibility_blocked", HeddleExitCode::DataErr),
491 ("capture_out_of_space", HeddleExitCode::IoErr),
492 ] {
493 assert_eq!(
494 HeddleExitCode::from_error(&advice_with_kind(kind)),
495 expected,
496 "advice kind `{kind}` must classify by kind, not message text"
497 );
498 }
499 }
500
501 #[test]
502 fn dirty_worktree_advice_constructor_is_data_err() {
503 let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::dirty_worktree(
506 "merge",
507 vec!["src/lib.rs".to_string()],
508 "repository state was left unchanged",
509 ));
510 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
511 }
512
513 #[test]
514 fn dirty_worktree_recovery_details_are_data_err() {
515 let err: anyhow::Error =
516 objects::error::HeddleError::recovery(objects::RecoveryDetails::safety_refusal(
517 "dirty_worktree",
518 "reworded copy that matches no sentinel",
519 "hint",
520 "unsafe",
521 "would change",
522 "preserved",
523 ))
524 .into();
525 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
526 }
527
528 #[test]
529 fn unsupported_output_advice_is_data_err() {
530 let json = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_unsupported(
534 "shell completion"
535 ));
536 assert_eq!(HeddleExitCode::from_error(&json), HeddleExitCode::DataErr);
537
538 let compact =
539 anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_compact_unsupported("log"));
540 assert_eq!(
541 HeddleExitCode::from_error(&compact),
542 HeddleExitCode::DataErr
543 );
544 }
545
546 #[test]
547 fn state_corrupted_recovery_details_are_data_err() {
548 let err: anyhow::Error = objects::error::HeddleError::recovery(
549 objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
550 )
551 .into();
552 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
553 }
554
555 #[test]
556 fn unclassified_advice_kind_falls_back_to_io_err() {
557 assert_eq!(
560 HeddleExitCode::from_error(&advice_with_kind("hook_veto")),
561 HeddleExitCode::IoErr
562 );
563 }
564
565 #[test]
566 fn outcome_exit_maps_to_its_code() {
567 let err = anyhow::anyhow!(OutcomeExit::data_err());
568 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
569 assert!(HeddleExitCode::is_quiet_outcome(&err));
570 }
571
572 #[test]
573 fn capture_out_of_space_advice_is_io_err() {
574 assert_eq!(
575 HeddleExitCode::from_error(&advice_with_kind("capture_out_of_space")),
576 HeddleExitCode::IoErr
577 );
578 }
579
580 #[test]
581 fn operator_blocked_advice_is_data_err() {
582 assert_eq!(
583 HeddleExitCode::from_error(&advice_with_kind("operator_blocked")),
584 HeddleExitCode::DataErr
585 );
586 assert_eq!(
587 HeddleExitCode::from_error(&advice_with_kind("merge_eligibility_blocked")),
588 HeddleExitCode::DataErr
589 );
590 }
591
592 #[test]
593 fn unknown_falls_back_to_io_err() {
594 let err = anyhow::anyhow!("some unrelated thing went wrong");
595 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::IoErr);
596 }
597
598 #[test]
599 fn u8_repr_matches_sysexits() {
600 assert_eq!(HeddleExitCode::Ok.as_u8(), 0);
601 assert_eq!(HeddleExitCode::Usage.as_u8(), 64);
602 assert_eq!(HeddleExitCode::DataErr.as_u8(), 65);
603 assert_eq!(HeddleExitCode::CantCreat.as_u8(), 73);
604 assert_eq!(HeddleExitCode::IoErr.as_u8(), 74);
605 assert_eq!(HeddleExitCode::TempFail.as_u8(), 75);
606 assert_eq!(HeddleExitCode::Protocol.as_u8(), 76);
607 assert_eq!(HeddleExitCode::NoPerm.as_u8(), 77);
608 assert_eq!(HeddleExitCode::Config.as_u8(), 78);
609 }
610}