1use std::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
44impl HeddleExitCode {
45 pub fn from_clap(err: &clap::Error) -> Self {
48 match err.kind() {
49 ClapErrorKind::DisplayHelp | ClapErrorKind::DisplayVersion => Self::Ok,
50 _ => Self::Usage,
51 }
52 }
53
54 fn for_advice_kind(kind: &str) -> Option<Self> {
60 match kind {
61 "remote_not_configured" | "remote_not_found" | "repository_not_found" => {
64 Some(Self::Config)
65 }
66 "nothing_to_commit"
75 | "reconcile_direction_required"
76 | "dirty_worktree"
77 | "state_corrupted"
78 | "state_not_found"
79 | "conflict_not_found"
80 | "no_merge_in_progress"
81 | "operation_not_in_progress"
82 | "json_unsupported"
83 | "json_compact_unsupported" => Some(Self::DataErr),
84 _ => None,
85 }
86 }
87
88 pub fn from_error(err: &anyhow::Error) -> Self {
92 for cause in err.chain() {
93 if let Some(advice) = cause.downcast_ref::<crate::cli::commands::RecoveryAdvice>()
98 && let Some(code) = Self::for_advice_kind(advice.kind)
99 {
100 return code;
101 }
102 if let Some(heddle_err) = cause.downcast_ref::<objects::error::HeddleError>() {
103 match heddle_err {
104 objects::error::HeddleError::Recovery(details) => {
105 if let Some(code) = Self::for_advice_kind(details.kind) {
106 return code;
107 }
108 }
109 objects::error::HeddleError::RepositoryNotFound(_) => return Self::Config,
112 objects::error::HeddleError::RepositoryFormatTooNew { .. } => {
113 return Self::DataErr;
114 }
115 objects::error::HeddleError::StateNotFound(_)
116 | objects::error::HeddleError::NoMergeInProgress
117 | objects::error::HeddleError::ConfigInvalidValue { .. } => {
118 return Self::DataErr;
119 }
120 objects::error::HeddleError::Config(_) => return Self::Config,
121 objects::error::HeddleError::Serialization(_) => return Self::DataErr,
125 _ => {}
126 }
127 }
128 if let Some(remote_err) = cause.downcast_ref::<crate::remote::RemoteError>()
129 && matches!(
130 remote_err,
131 crate::remote::RemoteError::NotFound(_)
132 | crate::remote::RemoteError::NoDefaultRemote
133 )
134 {
135 return Self::Config;
136 }
137 if let Some(io) = cause.downcast_ref::<std::io::Error>() {
138 return match io.kind() {
139 IoErrorKind::PermissionDenied => Self::NoPerm,
140 IoErrorKind::TimedOut
141 | IoErrorKind::ConnectionRefused
142 | IoErrorKind::ConnectionAborted
143 | IoErrorKind::ConnectionReset
144 | IoErrorKind::Interrupted => Self::TempFail,
145 IoErrorKind::NotFound | IoErrorKind::AlreadyExists => Self::CantCreat,
146 _ => Self::IoErr,
147 };
148 }
149 if let Some(status) = cause.downcast_ref::<tonic::Status>() {
150 use tonic::Code;
151 return match status.code() {
152 Code::Unavailable | Code::DeadlineExceeded | Code::ResourceExhausted => {
153 Self::TempFail
154 }
155 Code::InvalidArgument | Code::FailedPrecondition | Code::OutOfRange => {
156 Self::Protocol
157 }
158 Code::PermissionDenied | Code::Unauthenticated => Self::NoPerm,
159 Code::NotFound => Self::Config,
160 _ => Self::IoErr,
161 };
162 }
163 if cause.is::<serde_json::Error>() || cause.is::<toml::de::Error>() {
164 return Self::DataErr;
165 }
166 }
167
168 Self::IoErr
169 }
170
171 pub fn as_u8(self) -> u8 {
172 self as u8
173 }
174}
175
176impl From<HeddleExitCode> for i32 {
177 fn from(code: HeddleExitCode) -> Self {
178 code as i32
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn io_permission_denied_maps_to_noperm() {
188 let err: anyhow::Error =
189 std::io::Error::new(IoErrorKind::PermissionDenied, "denied").into();
190 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::NoPerm);
191 }
192
193 #[test]
194 fn io_timed_out_is_retry_safe() {
195 let err: anyhow::Error = std::io::Error::new(IoErrorKind::TimedOut, "slow").into();
196 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::TempFail);
197 }
198
199 #[test]
200 fn config_parse_preserves_toml_source_as_data_err() {
201 let toml_err = toml::from_str::<toml::Value>("= nope").unwrap_err();
206 let err: anyhow::Error = objects::error::HeddleError::ConfigParse {
207 path: std::path::PathBuf::from("/tmp/config.toml"),
208 source: toml_err,
209 }
210 .into();
211 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
212 }
213
214 #[test]
215 fn serde_json_is_data_err() {
216 let err: anyhow::Error = serde_json::from_str::<serde_json::Value>("{")
217 .unwrap_err()
218 .into();
219 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
220 }
221
222 #[test]
223 fn remote_error_no_default_remote_is_config() {
224 let err = anyhow::anyhow!(crate::remote::RemoteError::NoDefaultRemote);
225 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
226 }
227
228 #[test]
229 fn heddle_config_error_is_config() {
230 let err: anyhow::Error =
231 objects::error::HeddleError::Config("workspace config invalid".to_string()).into();
232 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
233 }
234
235 #[test]
236 fn remote_not_configured_advice_is_config() {
237 let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::remote_not_configured(
241 "push"
242 ));
243 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
244 }
245
246 #[test]
247 fn nothing_to_commit_advice_is_data_err() {
248 let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
251 "nothing_to_commit",
252 "nothing to commit",
253 "hint",
254 "unsafe",
255 "would change",
256 "preserved",
257 "heddle status",
258 vec!["heddle status".to_string()],
259 );
260 let err = anyhow::anyhow!(advice);
261 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
262 }
263
264 #[test]
265 fn reconcile_direction_required_advice_is_data_err() {
266 let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
269 "reconcile_direction_required",
270 "Refusing to reconcile 'main': choose a local side before applying",
271 "hint",
272 "unsafe",
273 "would change",
274 "preserved",
275 "heddle status",
276 vec!["heddle status".to_string()],
277 );
278 let err = anyhow::anyhow!(advice);
279 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
280 }
281
282 #[test]
283 fn repository_not_found_recovery_details_are_config() {
284 let err: anyhow::Error = objects::error::HeddleError::recovery(
285 objects::RecoveryDetails::repository_not_found(std::path::Path::new("/tmp/whatever")),
286 )
287 .into();
288 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
289 }
290
291 #[test]
292 fn repository_not_found_typed_variant_is_config() {
293 let err: anyhow::Error = objects::error::HeddleError::RepositoryNotFound(
296 std::path::PathBuf::from("/tmp/whatever"),
297 )
298 .into();
299 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
300 }
301
302 #[test]
303 fn serialization_error_typed_variant_is_data_err() {
304 let err: anyhow::Error = objects::error::HeddleError::Serialization(
307 "wrong msgpack marker FixArray(0)".to_string(),
308 )
309 .into();
310 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
311 }
312
313 #[test]
314 fn state_not_found_typed_variant_is_data_err() {
315 let err: anyhow::Error =
316 objects::error::HeddleError::StateNotFound(objects::object::ChangeId::generate())
317 .into();
318 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
319 }
320
321 #[test]
322 fn invalid_config_value_typed_variant_is_data_err() {
323 let err: anyhow::Error = objects::error::HeddleError::ConfigInvalidValue {
324 path: std::path::PathBuf::from("/tmp/config.toml"),
325 key: "output.format".to_string(),
326 value: "auto".to_string(),
327 valid_values: vec!["'text'".to_string(), "'json'".to_string()],
328 }
329 .into();
330 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
331 }
332
333 #[test]
334 fn no_merge_in_progress_typed_variant_is_data_err() {
335 let err: anyhow::Error = objects::error::HeddleError::NoMergeInProgress.into();
336 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
337 }
338
339 #[test]
340 fn recovery_details_kind_uses_advice_exit_code_mapping() {
341 let err: anyhow::Error = objects::error::HeddleError::recovery(
342 objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
343 )
344 .into();
345 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
346 }
347
348 fn advice_with_kind(kind: &'static str) -> anyhow::Error {
352 anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::safety_refusal(
353 kind,
354 "reworded copy that matches no sentinel",
355 "hint",
356 "unsafe",
357 "would change",
358 "preserved",
359 "heddle status",
360 vec!["heddle status".to_string()],
361 ))
362 }
363
364 #[test]
365 fn every_classified_advice_kind_maps_to_its_documented_exit_code() {
366 for (kind, expected) in [
369 ("remote_not_configured", HeddleExitCode::Config),
370 ("remote_not_found", HeddleExitCode::Config),
371 ("repository_not_found", HeddleExitCode::Config),
372 ("nothing_to_commit", HeddleExitCode::DataErr),
373 ("reconcile_direction_required", HeddleExitCode::DataErr),
374 ("dirty_worktree", HeddleExitCode::DataErr),
375 ("state_corrupted", HeddleExitCode::DataErr),
376 ("state_not_found", HeddleExitCode::DataErr),
377 ("no_merge_in_progress", HeddleExitCode::DataErr),
378 ("operation_not_in_progress", HeddleExitCode::DataErr),
379 ("conflict_not_found", HeddleExitCode::DataErr),
380 ("json_unsupported", HeddleExitCode::DataErr),
381 ("json_compact_unsupported", HeddleExitCode::DataErr),
382 ] {
383 assert_eq!(
384 HeddleExitCode::from_error(&advice_with_kind(kind)),
385 expected,
386 "advice kind `{kind}` must classify by kind, not message text"
387 );
388 }
389 }
390
391 #[test]
392 fn dirty_worktree_advice_constructor_is_data_err() {
393 let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::dirty_worktree(
396 "merge",
397 vec!["src/lib.rs".to_string()],
398 "repository state was left unchanged",
399 ));
400 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
401 }
402
403 #[test]
404 fn dirty_worktree_recovery_details_are_data_err() {
405 let err: anyhow::Error =
406 objects::error::HeddleError::recovery(objects::RecoveryDetails::safety_refusal(
407 "dirty_worktree",
408 "reworded copy that matches no sentinel",
409 "hint",
410 "unsafe",
411 "would change",
412 "preserved",
413 ))
414 .into();
415 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
416 }
417
418 #[test]
419 fn unsupported_output_advice_is_data_err() {
420 let json = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_unsupported(
424 "shell completion"
425 ));
426 assert_eq!(HeddleExitCode::from_error(&json), HeddleExitCode::DataErr);
427
428 let compact =
429 anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_compact_unsupported("log"));
430 assert_eq!(
431 HeddleExitCode::from_error(&compact),
432 HeddleExitCode::DataErr
433 );
434 }
435
436 #[test]
437 fn state_corrupted_recovery_details_are_data_err() {
438 let err: anyhow::Error = objects::error::HeddleError::recovery(
439 objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
440 )
441 .into();
442 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
443 }
444
445 #[test]
446 fn unclassified_advice_kind_falls_back_to_io_err() {
447 assert_eq!(
450 HeddleExitCode::from_error(&advice_with_kind("hook_veto")),
451 HeddleExitCode::IoErr
452 );
453 }
454
455 #[test]
456 fn unknown_falls_back_to_io_err() {
457 let err = anyhow::anyhow!("some unrelated thing went wrong");
458 assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::IoErr);
459 }
460
461 #[test]
462 fn u8_repr_matches_sysexits() {
463 assert_eq!(HeddleExitCode::Ok.as_u8(), 0);
464 assert_eq!(HeddleExitCode::Usage.as_u8(), 64);
465 assert_eq!(HeddleExitCode::DataErr.as_u8(), 65);
466 assert_eq!(HeddleExitCode::CantCreat.as_u8(), 73);
467 assert_eq!(HeddleExitCode::IoErr.as_u8(), 74);
468 assert_eq!(HeddleExitCode::TempFail.as_u8(), 75);
469 assert_eq!(HeddleExitCode::Protocol.as_u8(), 76);
470 assert_eq!(HeddleExitCode::NoPerm.as_u8(), 77);
471 assert_eq!(HeddleExitCode::Config.as_u8(), 78);
472 }
473}