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