use std::io::ErrorKind as IoErrorKind;
use clap::error::ErrorKind as ClapErrorKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum HeddleExitCode {
Ok = 0,
Usage = 64,
DataErr = 65,
CantCreat = 73,
IoErr = 74,
TempFail = 75,
Protocol = 76,
NoPerm = 77,
Config = 78,
}
impl HeddleExitCode {
pub fn from_clap(err: &clap::Error) -> Self {
match err.kind() {
ClapErrorKind::DisplayHelp | ClapErrorKind::DisplayVersion => Self::Ok,
_ => Self::Usage,
}
}
fn for_advice_kind(kind: &str) -> Option<Self> {
match kind {
"remote_not_configured" | "remote_not_found" | "repository_not_found" => {
Some(Self::Config)
}
"nothing_to_commit"
| "reconcile_direction_required"
| "dirty_worktree"
| "state_corrupted"
| "state_not_found"
| "conflict_not_found"
| "no_merge_in_progress"
| "operation_not_in_progress"
| "json_unsupported"
| "json_compact_unsupported" => Some(Self::DataErr),
_ => None,
}
}
pub fn from_error(err: &anyhow::Error) -> Self {
for cause in err.chain() {
if let Some(advice) = cause.downcast_ref::<crate::cli::commands::RecoveryAdvice>()
&& let Some(code) = Self::for_advice_kind(advice.kind)
{
return code;
}
if let Some(heddle_err) = cause.downcast_ref::<objects::error::HeddleError>() {
match heddle_err {
objects::error::HeddleError::Recovery(details) => {
if let Some(code) = Self::for_advice_kind(details.kind) {
return code;
}
}
objects::error::HeddleError::RepositoryNotFound(_) => return Self::Config,
objects::error::HeddleError::RepositoryFormatTooNew { .. } => {
return Self::DataErr;
}
objects::error::HeddleError::StateNotFound(_)
| objects::error::HeddleError::NoMergeInProgress
| objects::error::HeddleError::ConfigInvalidValue { .. } => {
return Self::DataErr;
}
objects::error::HeddleError::Config(_) => return Self::Config,
objects::error::HeddleError::Serialization(_) => return Self::DataErr,
_ => {}
}
}
if let Some(remote_err) = cause.downcast_ref::<crate::remote::RemoteError>()
&& matches!(
remote_err,
crate::remote::RemoteError::NotFound(_)
| crate::remote::RemoteError::NoDefaultRemote
)
{
return Self::Config;
}
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
return match io.kind() {
IoErrorKind::PermissionDenied => Self::NoPerm,
IoErrorKind::TimedOut
| IoErrorKind::ConnectionRefused
| IoErrorKind::ConnectionAborted
| IoErrorKind::ConnectionReset
| IoErrorKind::Interrupted => Self::TempFail,
IoErrorKind::NotFound | IoErrorKind::AlreadyExists => Self::CantCreat,
_ => Self::IoErr,
};
}
if let Some(status) = cause.downcast_ref::<tonic::Status>() {
use tonic::Code;
return match status.code() {
Code::Unavailable | Code::DeadlineExceeded | Code::ResourceExhausted => {
Self::TempFail
}
Code::InvalidArgument | Code::FailedPrecondition | Code::OutOfRange => {
Self::Protocol
}
Code::PermissionDenied | Code::Unauthenticated => Self::NoPerm,
Code::NotFound => Self::Config,
_ => Self::IoErr,
};
}
if cause.is::<serde_json::Error>() || cause.is::<toml::de::Error>() {
return Self::DataErr;
}
}
Self::IoErr
}
pub fn as_u8(self) -> u8 {
self as u8
}
}
impl From<HeddleExitCode> for i32 {
fn from(code: HeddleExitCode) -> Self {
code as i32
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn io_permission_denied_maps_to_noperm() {
let err: anyhow::Error =
std::io::Error::new(IoErrorKind::PermissionDenied, "denied").into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::NoPerm);
}
#[test]
fn io_timed_out_is_retry_safe() {
let err: anyhow::Error = std::io::Error::new(IoErrorKind::TimedOut, "slow").into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::TempFail);
}
#[test]
fn config_parse_preserves_toml_source_as_data_err() {
let toml_err = toml::from_str::<toml::Value>("= nope").unwrap_err();
let err: anyhow::Error = objects::error::HeddleError::ConfigParse {
path: std::path::PathBuf::from("/tmp/config.toml"),
source: toml_err,
}
.into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
}
#[test]
fn serde_json_is_data_err() {
let err: anyhow::Error = serde_json::from_str::<serde_json::Value>("{")
.unwrap_err()
.into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
}
#[test]
fn remote_error_no_default_remote_is_config() {
let err = anyhow::anyhow!(crate::remote::RemoteError::NoDefaultRemote);
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
}
#[test]
fn heddle_config_error_is_config() {
let err: anyhow::Error =
objects::error::HeddleError::Config("workspace config invalid".to_string()).into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
}
#[test]
fn remote_not_configured_advice_is_config() {
let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::remote_not_configured(
"push"
));
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
}
#[test]
fn nothing_to_commit_advice_is_data_err() {
let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
"nothing_to_commit",
"nothing to commit",
"hint",
"unsafe",
"would change",
"preserved",
"heddle status",
vec!["heddle status".to_string()],
);
let err = anyhow::anyhow!(advice);
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
}
#[test]
fn reconcile_direction_required_advice_is_data_err() {
let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
"reconcile_direction_required",
"Refusing to reconcile 'main': choose a local side before applying",
"hint",
"unsafe",
"would change",
"preserved",
"heddle status",
vec!["heddle status".to_string()],
);
let err = anyhow::anyhow!(advice);
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
}
#[test]
fn repository_not_found_recovery_details_are_config() {
let err: anyhow::Error = objects::error::HeddleError::recovery(
objects::RecoveryDetails::repository_not_found(std::path::Path::new("/tmp/whatever")),
)
.into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
}
#[test]
fn repository_not_found_typed_variant_is_config() {
let err: anyhow::Error = objects::error::HeddleError::RepositoryNotFound(
std::path::PathBuf::from("/tmp/whatever"),
)
.into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
}
#[test]
fn serialization_error_typed_variant_is_data_err() {
let err: anyhow::Error = objects::error::HeddleError::Serialization(
"wrong msgpack marker FixArray(0)".to_string(),
)
.into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
}
#[test]
fn state_not_found_typed_variant_is_data_err() {
let err: anyhow::Error =
objects::error::HeddleError::StateNotFound(objects::object::ChangeId::generate())
.into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
}
#[test]
fn invalid_config_value_typed_variant_is_data_err() {
let err: anyhow::Error = objects::error::HeddleError::ConfigInvalidValue {
path: std::path::PathBuf::from("/tmp/config.toml"),
key: "output.format".to_string(),
value: "auto".to_string(),
valid_values: vec!["'text'".to_string(), "'json'".to_string()],
}
.into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
}
#[test]
fn no_merge_in_progress_typed_variant_is_data_err() {
let err: anyhow::Error = objects::error::HeddleError::NoMergeInProgress.into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
}
#[test]
fn recovery_details_kind_uses_advice_exit_code_mapping() {
let err: anyhow::Error = objects::error::HeddleError::recovery(
objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
)
.into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
}
fn advice_with_kind(kind: &'static str) -> anyhow::Error {
anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::safety_refusal(
kind,
"reworded copy that matches no sentinel",
"hint",
"unsafe",
"would change",
"preserved",
"heddle status",
vec!["heddle status".to_string()],
))
}
#[test]
fn every_classified_advice_kind_maps_to_its_documented_exit_code() {
for (kind, expected) in [
("remote_not_configured", HeddleExitCode::Config),
("remote_not_found", HeddleExitCode::Config),
("repository_not_found", HeddleExitCode::Config),
("nothing_to_commit", HeddleExitCode::DataErr),
("reconcile_direction_required", HeddleExitCode::DataErr),
("dirty_worktree", HeddleExitCode::DataErr),
("state_corrupted", HeddleExitCode::DataErr),
("state_not_found", HeddleExitCode::DataErr),
("no_merge_in_progress", HeddleExitCode::DataErr),
("operation_not_in_progress", HeddleExitCode::DataErr),
("conflict_not_found", HeddleExitCode::DataErr),
("json_unsupported", HeddleExitCode::DataErr),
("json_compact_unsupported", HeddleExitCode::DataErr),
] {
assert_eq!(
HeddleExitCode::from_error(&advice_with_kind(kind)),
expected,
"advice kind `{kind}` must classify by kind, not message text"
);
}
}
#[test]
fn dirty_worktree_advice_constructor_is_data_err() {
let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::dirty_worktree(
"merge",
vec!["src/lib.rs".to_string()],
"repository state was left unchanged",
));
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
}
#[test]
fn dirty_worktree_recovery_details_are_data_err() {
let err: anyhow::Error =
objects::error::HeddleError::recovery(objects::RecoveryDetails::safety_refusal(
"dirty_worktree",
"reworded copy that matches no sentinel",
"hint",
"unsafe",
"would change",
"preserved",
))
.into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
}
#[test]
fn unsupported_output_advice_is_data_err() {
let json = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_unsupported(
"shell completion"
));
assert_eq!(HeddleExitCode::from_error(&json), HeddleExitCode::DataErr);
let compact =
anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_compact_unsupported("log"));
assert_eq!(
HeddleExitCode::from_error(&compact),
HeddleExitCode::DataErr
);
}
#[test]
fn state_corrupted_recovery_details_are_data_err() {
let err: anyhow::Error = objects::error::HeddleError::recovery(
objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
)
.into();
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
}
#[test]
fn unclassified_advice_kind_falls_back_to_io_err() {
assert_eq!(
HeddleExitCode::from_error(&advice_with_kind("hook_veto")),
HeddleExitCode::IoErr
);
}
#[test]
fn unknown_falls_back_to_io_err() {
let err = anyhow::anyhow!("some unrelated thing went wrong");
assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::IoErr);
}
#[test]
fn u8_repr_matches_sysexits() {
assert_eq!(HeddleExitCode::Ok.as_u8(), 0);
assert_eq!(HeddleExitCode::Usage.as_u8(), 64);
assert_eq!(HeddleExitCode::DataErr.as_u8(), 65);
assert_eq!(HeddleExitCode::CantCreat.as_u8(), 73);
assert_eq!(HeddleExitCode::IoErr.as_u8(), 74);
assert_eq!(HeddleExitCode::TempFail.as_u8(), 75);
assert_eq!(HeddleExitCode::Protocol.as_u8(), 76);
assert_eq!(HeddleExitCode::NoPerm.as_u8(), 77);
assert_eq!(HeddleExitCode::Config.as_u8(), 78);
}
}