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