Skip to main content

cli/
exit.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Heddle CLI exit code taxonomy.
3//!
4//! Agents that retry on transient failures need codified exit codes so they
5//! can distinguish "safe to retry" from "permanent failure" without parsing
6//! stderr. The taxonomy follows BSD `sysexits.h` so the codes mean the same
7//! thing to humans, init systems, and shell scripts that already understand
8//! them.
9//!
10//! `0` is success; `2` is reserved for `set -e` / panic / unhandled error and
11//! is never emitted intentionally — we let it surface naturally.
12
13use 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    /// `EX_USAGE` — invalid CLI args, unknown subcommand, malformed flag.
22    Usage = 64,
23    /// `EX_DATAERR` — well-formed input but semantically rejected (malformed
24    /// repo, unmergeable divergence, parse error in a tracked file).
25    DataErr = 65,
26    /// `EX_CANTCREAT` — output file refused (write target exists, parent
27    /// unwritable, state dir uncreatable).
28    CantCreat = 73,
29    /// `EX_IOERR` — generic IO failure during read/write.
30    IoErr = 74,
31    /// `EX_TEMPFAIL` — transient failure; same command with the same args
32    /// is safe to retry.
33    TempFail = 75,
34    /// `EX_PROTOCOL` — remote rejected the payload at the protocol layer;
35    /// retrying without changing inputs will fail the same way.
36    Protocol = 76,
37    /// `EX_NOPERM` — operation refused for permission reasons.
38    NoPerm = 77,
39    /// `EX_CONFIG` — configuration is missing, ambiguous, or invalid (no
40    /// upstream, no remote, conflicting user identity).
41    Config = 78,
42}
43
44/// Command already rendered its user-visible outcome (operator envelope,
45/// eligibility report, etc.) and only needs a non-zero process exit.
46///
47/// `main` maps this through [`HeddleExitCode::from_error`] and **does not**
48/// print a second error envelope — the command body owns the render.
49#[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    /// Semantic rejection after a successful render (blocked operator,
64    /// unmet merge eligibility, …).
65    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    /// Map a clap parse error to an exit code. Help/version are not failures
84    /// (clap prints to stdout and exits 0); everything else is a usage error.
85    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    /// Exit code for a typed `RecoveryAdvice` kind whose documented code
93    /// differs from the `IoErr` catch-all. This table — not the
94    /// user-visible message — is the classification contract: rewording
95    /// advice copy can never regress an exit code, and every entry here is
96    /// pinned by a per-kind regression test below.
97    fn for_advice_kind(kind: &str) -> Option<Self> {
98        match kind {
99            // Missing precondition (no default remote for push/pull), not
100            // an IO failure.
101            "remote_not_configured" | "remote_not_found" | "repository_not_found" => {
102                Some(Self::Config)
103            }
104            // Well-formed input the command semantically rejects:
105            // - nothing staged / no changes to capture
106            // - a reconcile that needs a `--prefer` side
107            // - unsaved worktree changes blocking a tree write
108            // - repository state that fails msgpack/serde decoding
109            // - `--output json`/`json-compact` against a command without
110            //   that output contract (the invocation parses fine; the
111            //   command rejects the requested projection)
112            "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/land/ready/continue finished rendering a blocked or
126            // failed envelope — semantic rejection of well-formed input.
127            | "operator_blocked"
128            // Hosted merge eligibility gate refused the pair.
129            | "merge_eligibility_blocked" => Some(Self::DataErr),
130            // Capture aborted on ENOSPC; working tree is intact. Classifies
131            // as IO rather than a distinct raw-28 OS code so agents stay on
132            // the documented sysexits taxonomy.
133            "capture_out_of_space" => Some(Self::IoErr),
134            _ => None,
135        }
136    }
137
138    /// True when the error is a post-render outcome that must not print a
139    /// second stderr envelope (the command body already wrote the report).
140    pub fn is_quiet_outcome(err: &anyhow::Error) -> bool {
141        err.chain().any(|cause| cause.is::<OutcomeExit>())
142    }
143
144    /// Map an anyhow error chain to an exit code. Walks the chain and uses
145    /// the first downcast match; falls back to `IoErr` so callers always
146    /// get a code more informative than the bare `1` shell convention.
147    pub fn from_error(err: &anyhow::Error) -> Self {
148        for cause in err.chain() {
149            // Already-rendered command outcomes carry an explicit code and
150            // must not fall through to the IoErr catch-all.
151            if let Some(outcome) = cause.downcast_ref::<OutcomeExit>() {
152                return outcome.code();
153            }
154            // Typed refusals carry a stable `kind` discriminator — route the
155            // ones whose documented code differs from the `IoErr` catch-all.
156            // Keyed on `kind` (not the user-visible message) so rewording the
157            // error text can't silently regress the contract.
158            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                    // A missing repository is a missing precondition
171                    // (initialize/point at one), not an IO failure.
172                    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                    // Stored state that fails msgpack decoding is corrupted
187                    // data, not a transient IO problem — same class as the
188                    // serde_json/toml parse failures below.
189                    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(protocol) = cause.downcast_ref::<wire::ProtocolError>() {
203                if let Some(typed) =
204                    crate::hosted_failure::HostedFailureDetail::from_protocol_error(protocol)
205                    && let Some(code) = typed.exit_code()
206                {
207                    return code;
208                }
209                return match protocol {
210                    wire::ProtocolError::RemoteFailure { code, .. } => {
211                        crate::hosted_failure::exit_code_for_remote(*code)
212                    }
213                    wire::ProtocolError::AuthorizationFailed(_)
214                    | wire::ProtocolError::AuthenticationFailed(_) => Self::NoPerm,
215                    wire::ProtocolError::ObjectNotFound(_) => Self::Config,
216                    wire::ProtocolError::InvalidState(_)
217                    | wire::ProtocolError::AlreadyExists(_)
218                    | wire::ProtocolError::Serialization(_)
219                    | wire::ProtocolError::MessageTooLarge { .. }
220                    | wire::ProtocolError::InvalidMessageType(_)
221                    | wire::ProtocolError::VersionMismatch { .. }
222                    | wire::ProtocolError::CapabilityNotSupported(_) => Self::Protocol,
223                    wire::ProtocolError::Io(io) => match io.kind() {
224                        IoErrorKind::TimedOut
225                        | IoErrorKind::ConnectionRefused
226                        | IoErrorKind::ConnectionAborted
227                        | IoErrorKind::ConnectionReset
228                        | IoErrorKind::Interrupted => Self::TempFail,
229                        IoErrorKind::PermissionDenied => Self::NoPerm,
230                        _ => Self::IoErr,
231                    },
232                    wire::ProtocolError::Remote(_) | wire::ProtocolError::LockError(_) => {
233                        Self::IoErr
234                    }
235                };
236            }
237            if let Some(io) = cause.downcast_ref::<std::io::Error>() {
238                return match io.kind() {
239                    IoErrorKind::PermissionDenied => Self::NoPerm,
240                    IoErrorKind::TimedOut
241                    | IoErrorKind::ConnectionRefused
242                    | IoErrorKind::ConnectionAborted
243                    | IoErrorKind::ConnectionReset
244                    | IoErrorKind::Interrupted => Self::TempFail,
245                    IoErrorKind::NotFound | IoErrorKind::AlreadyExists => Self::CantCreat,
246                    _ => Self::IoErr,
247                };
248            }
249            if cause.is::<serde_json::Error>() || cause.is::<toml::de::Error>() {
250                return Self::DataErr;
251            }
252        }
253
254        Self::IoErr
255    }
256
257    pub fn as_u8(self) -> u8 {
258        self as u8
259    }
260}
261
262impl From<HeddleExitCode> for i32 {
263    fn from(code: HeddleExitCode) -> Self {
264        code as i32
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn io_permission_denied_maps_to_noperm() {
274        let err: anyhow::Error =
275            std::io::Error::new(IoErrorKind::PermissionDenied, "denied").into();
276        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::NoPerm);
277    }
278
279    #[test]
280    fn typed_repository_lock_failure_maps_to_tempfail() {
281        let err = objects::error::HeddleError::Lock(objects::lock::LockError::Acquire(
282            std::io::Error::new(IoErrorKind::WouldBlock, "contended"),
283        ));
284        assert_eq!(
285            HeddleExitCode::from_error(&anyhow::Error::new(err)),
286            HeddleExitCode::TempFail
287        );
288    }
289
290    #[test]
291    fn io_timed_out_is_retry_safe() {
292        let err: anyhow::Error = std::io::Error::new(IoErrorKind::TimedOut, "slow").into();
293        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::TempFail);
294    }
295
296    #[test]
297    fn config_parse_preserves_toml_source_as_data_err() {
298        // Regression for Codex R4 (cid 3315305484): `ConfigParse` must keep
299        // the `toml::de::Error` as its source so the chain-walk still
300        // classifies it, rather than flattening to a String and falling
301        // through to `IoErr`.
302        let toml_err = toml::from_str::<toml::Value>("= nope").unwrap_err();
303        let err: anyhow::Error = objects::error::HeddleError::ConfigParse {
304            path: std::path::PathBuf::from("/tmp/config.toml"),
305            source: toml_err,
306        }
307        .into();
308        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
309    }
310
311    #[test]
312    fn serde_json_is_data_err() {
313        let err: anyhow::Error = serde_json::from_str::<serde_json::Value>("{")
314            .unwrap_err()
315            .into();
316        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
317    }
318
319    #[test]
320    fn remote_error_no_default_remote_is_config() {
321        let err = anyhow::anyhow!(crate::remote::RemoteError::NoDefaultRemote);
322        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
323    }
324
325    #[test]
326    fn heddle_config_error_is_config() {
327        let err: anyhow::Error =
328            objects::error::HeddleError::Config("workspace config invalid".to_string()).into();
329        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
330    }
331
332    #[test]
333    fn remote_not_configured_advice_is_config() {
334        // `heddle push`/`heddle pull` with no default remote raise the typed
335        // `remote_not_configured` advice — a missing-precondition (Config),
336        // not the `IoErr` catch-all.
337        let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::remote_not_configured(
338            "push"
339        ));
340        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
341    }
342
343    #[test]
344    fn nothing_to_capture_advice_is_data_err() {
345        // `heddle capture` with nothing selected is semantic rejection of
346        // well-formed input (DataErr), not an IO failure.
347        let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
348            "nothing_to_capture",
349            "nothing to capture",
350            "hint",
351            "unsafe",
352            "would change",
353            "preserved",
354            "heddle status",
355            vec!["heddle status".to_string()],
356        );
357        let err = anyhow::anyhow!(advice);
358        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
359    }
360
361    #[test]
362    fn fsck_authority_refusal_is_data_err() {
363        // An authority-conflicting `heddle fsck repair git --prefer ...`
364        // request is a semantic refusal, not an IO failure.
365        let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
366            "git_repair_requires_adoption",
367            "Git owns source history in this repository",
368            "hint",
369            "unsafe",
370            "would change",
371            "preserved",
372            "heddle status",
373            vec!["heddle status".to_string()],
374        );
375        let err = anyhow::anyhow!(advice);
376        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
377    }
378
379    #[test]
380    fn repository_not_found_recovery_details_are_config() {
381        let err: anyhow::Error = objects::error::HeddleError::recovery(
382            objects::RecoveryDetails::repository_not_found(std::path::Path::new("/tmp/whatever")),
383        )
384        .into();
385        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
386    }
387
388    #[test]
389    fn repository_not_found_typed_variant_is_config() {
390        // The typed `HeddleError::RepositoryNotFound` must classify without
391        // relying on its Display text surviving a rewording.
392        let err: anyhow::Error = objects::error::HeddleError::RepositoryNotFound(
393            std::path::PathBuf::from("/tmp/whatever"),
394        )
395        .into();
396        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
397    }
398
399    #[test]
400    fn serialization_error_typed_variant_is_data_err() {
401        // Corrupted msgpack state (HeddleCo/heddle#642): decode failures
402        // are data corruption, not the IoErr catch-all.
403        let err: anyhow::Error = objects::error::HeddleError::Serialization(
404            "wrong msgpack marker FixArray(0)".to_string(),
405        )
406        .into();
407        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
408    }
409
410    #[test]
411    fn state_not_found_typed_variant_is_data_err() {
412        let err: anyhow::Error = objects::error::HeddleError::StateNotFound(
413            objects::object::StateId::from_bytes([3; 32]),
414        )
415        .into();
416        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
417    }
418
419    #[test]
420    fn invalid_config_value_typed_variant_is_data_err() {
421        let err: anyhow::Error = objects::error::HeddleError::ConfigInvalidValue {
422            path: std::path::PathBuf::from("/tmp/config.toml"),
423            key: "output.format".to_string(),
424            value: "auto".to_string(),
425            valid_values: vec!["'text'".to_string(), "'json'".to_string()],
426        }
427        .into();
428        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
429    }
430
431    #[test]
432    fn repository_format_migration_required_is_data_err() {
433        let err: anyhow::Error = objects::error::HeddleError::RepositoryFormatMigrationRequired {
434            path: std::path::PathBuf::from("/tmp/config.toml"),
435            found: 2,
436            required: 3,
437        }
438        .into();
439        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
440    }
441
442    #[test]
443    fn storage_format_migration_required_is_data_err() {
444        let err: anyhow::Error = objects::error::HeddleError::StorageFormatMigrationRequired {
445            storage: "packed oplog container".to_string(),
446            found: 2,
447            required: 4,
448        }
449        .into();
450        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
451    }
452
453    #[test]
454    fn no_merge_in_progress_typed_variant_is_data_err() {
455        let err: anyhow::Error = objects::error::HeddleError::NoMergeInProgress.into();
456        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
457    }
458
459    #[test]
460    fn recovery_details_kind_uses_advice_exit_code_mapping() {
461        let err: anyhow::Error = objects::error::HeddleError::recovery(
462            objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
463        )
464        .into();
465        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
466    }
467
468    /// Build a `RecoveryAdvice` with the given kind and deliberately
469    /// unrelated copy, proving classification reads `kind`, never the
470    /// user-visible message (HeddleCo/heddle#640).
471    fn advice_with_kind(kind: &'static str) -> anyhow::Error {
472        anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::safety_refusal(
473            kind,
474            "reworded copy that matches no sentinel",
475            "hint",
476            "unsafe",
477            "would change",
478            "preserved",
479            "heddle status",
480            vec!["heddle status".to_string()],
481        ))
482    }
483
484    #[test]
485    fn every_classified_advice_kind_maps_to_its_documented_exit_code() {
486        // Per-kind regression matrix: copy edits that orphan a string
487        // sentinel can no longer regress these to the IoErr catch-all.
488        for (kind, expected) in [
489            ("remote_not_configured", HeddleExitCode::Config),
490            ("remote_not_found", HeddleExitCode::Config),
491            ("repository_not_found", HeddleExitCode::Config),
492            ("nothing_to_capture", HeddleExitCode::DataErr),
493            ("dirty_worktree", HeddleExitCode::DataErr),
494            ("state_corrupted", HeddleExitCode::DataErr),
495            ("state_not_found", HeddleExitCode::DataErr),
496            ("no_merge_in_progress", HeddleExitCode::DataErr),
497            ("operation_not_in_progress", HeddleExitCode::DataErr),
498            ("conflict_not_found", HeddleExitCode::DataErr),
499            ("json_unsupported", HeddleExitCode::DataErr),
500            ("json_compact_unsupported", HeddleExitCode::DataErr),
501            ("operator_blocked", HeddleExitCode::DataErr),
502            ("merge_eligibility_blocked", HeddleExitCode::DataErr),
503            ("capture_out_of_space", HeddleExitCode::IoErr),
504        ] {
505            assert_eq!(
506                HeddleExitCode::from_error(&advice_with_kind(kind)),
507                expected,
508                "advice kind `{kind}` must classify by kind, not message text"
509            );
510        }
511    }
512
513    #[test]
514    fn dirty_worktree_advice_constructor_is_data_err() {
515        // The real constructor's Display does not contain the legacy
516        // "dirty worktree" phrase, so only the typed kind can classify it.
517        let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::dirty_worktree(
518            "merge",
519            vec!["src/lib.rs".to_string()],
520            "repository state was left unchanged",
521        ));
522        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
523    }
524
525    #[test]
526    fn dirty_worktree_recovery_details_are_data_err() {
527        let err: anyhow::Error =
528            objects::error::HeddleError::recovery(objects::RecoveryDetails::safety_refusal(
529                "dirty_worktree",
530                "reworded copy that matches no sentinel",
531                "hint",
532                "unsafe",
533                "would change",
534                "preserved",
535            ))
536            .into();
537        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
538    }
539
540    #[test]
541    fn unsupported_output_advice_is_data_err() {
542        // HeddleCo/heddle#648: `--output json[-compact]` against a command
543        // without that contract is semantic rejection of well-formed input
544        // (DataErr 65), not a malformed invocation (Usage 64).
545        let json = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_unsupported(
546            "shell completion"
547        ));
548        assert_eq!(HeddleExitCode::from_error(&json), HeddleExitCode::DataErr);
549
550        let compact =
551            anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_compact_unsupported("log"));
552        assert_eq!(
553            HeddleExitCode::from_error(&compact),
554            HeddleExitCode::DataErr
555        );
556    }
557
558    #[test]
559    fn state_corrupted_recovery_details_are_data_err() {
560        let err: anyhow::Error = objects::error::HeddleError::recovery(
561            objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
562        )
563        .into();
564        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
565    }
566
567    #[test]
568    fn unclassified_advice_kind_falls_back_to_io_err() {
569        // Kinds without a documented divergent code keep the catch-all, so
570        // adding a new advice kind never silently changes an exit code.
571        assert_eq!(
572            HeddleExitCode::from_error(&advice_with_kind("hook_veto")),
573            HeddleExitCode::IoErr
574        );
575    }
576
577    #[test]
578    fn outcome_exit_maps_to_its_code() {
579        let err = anyhow::anyhow!(OutcomeExit::data_err());
580        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
581        assert!(HeddleExitCode::is_quiet_outcome(&err));
582    }
583
584    #[test]
585    fn capture_out_of_space_advice_is_io_err() {
586        assert_eq!(
587            HeddleExitCode::from_error(&advice_with_kind("capture_out_of_space")),
588            HeddleExitCode::IoErr
589        );
590    }
591
592    #[test]
593    fn operator_blocked_advice_is_data_err() {
594        assert_eq!(
595            HeddleExitCode::from_error(&advice_with_kind("operator_blocked")),
596            HeddleExitCode::DataErr
597        );
598        assert_eq!(
599            HeddleExitCode::from_error(&advice_with_kind("merge_eligibility_blocked")),
600            HeddleExitCode::DataErr
601        );
602    }
603
604    #[test]
605    fn unknown_falls_back_to_io_err() {
606        let err = anyhow::anyhow!("some unrelated thing went wrong");
607        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::IoErr);
608    }
609
610    #[test]
611    fn u8_repr_matches_sysexits() {
612        assert_eq!(HeddleExitCode::Ok.as_u8(), 0);
613        assert_eq!(HeddleExitCode::Usage.as_u8(), 64);
614        assert_eq!(HeddleExitCode::DataErr.as_u8(), 65);
615        assert_eq!(HeddleExitCode::CantCreat.as_u8(), 73);
616        assert_eq!(HeddleExitCode::IoErr.as_u8(), 74);
617        assert_eq!(HeddleExitCode::TempFail.as_u8(), 75);
618        assert_eq!(HeddleExitCode::Protocol.as_u8(), 76);
619        assert_eq!(HeddleExitCode::NoPerm.as_u8(), 77);
620        assert_eq!(HeddleExitCode::Config.as_u8(), 78);
621    }
622}