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::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
44impl HeddleExitCode {
45    /// Map a clap parse error to an exit code. Help/version are not failures
46    /// (clap prints to stdout and exits 0); everything else is a usage error.
47    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    /// Exit code for a typed `RecoveryAdvice` kind whose documented code
55    /// differs from the `IoErr` catch-all. This table — not the
56    /// user-visible message — is the classification contract: rewording
57    /// advice copy can never regress an exit code, and every entry here is
58    /// pinned by a per-kind regression test below.
59    fn for_advice_kind(kind: &str) -> Option<Self> {
60        match kind {
61            // Missing precondition (no default remote for push/pull), not
62            // an IO failure.
63            "remote_not_configured" | "remote_not_found" | "repository_not_found" => {
64                Some(Self::Config)
65            }
66            // Well-formed input the command semantically rejects:
67            // - nothing staged / no changes to capture
68            // - a reconcile that needs a `--prefer` side
69            // - unsaved worktree changes blocking a tree write
70            // - repository state that fails msgpack/serde decoding
71            // - `--output json`/`json-compact` against a command without
72            //   that output contract (the invocation parses fine; the
73            //   command rejects the requested projection)
74            "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    /// Map an anyhow error chain to an exit code. Walks the chain and uses
90    /// the first downcast match; falls back to `IoErr` so callers always
91    /// get a code more informative than the bare `1` shell convention.
92    pub fn from_error(err: &anyhow::Error) -> Self {
93        for cause in err.chain() {
94            // Typed refusals carry a stable `kind` discriminator — route the
95            // ones whose documented code differs from the `IoErr` catch-all.
96            // Keyed on `kind` (not the user-visible message) so rewording the
97            // error text can't silently regress the contract.
98            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                    // A missing repository is a missing precondition
111                    // (initialize/point at one), not an IO failure.
112                    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                    // Stored state that fails msgpack decoding is corrupted
123                    // data, not a transient IO problem — same class as the
124                    // serde_json/toml parse failures below.
125                    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        // Regression for Codex R4 (cid 3315305484): `ConfigParse` must keep
203        // the `toml::de::Error` as its source so the chain-walk still
204        // classifies it, rather than flattening to a String and falling
205        // through to `IoErr`.
206        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        // `heddle push`/`heddle pull` with no default remote raise the typed
239        // `remote_not_configured` advice — a missing-precondition (Config),
240        // not the `IoErr` catch-all.
241        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        // `heddle commit` with nothing staged is semantic rejection of
250        // well-formed input (DataErr), not an IO failure.
251        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        // `heddle fsck --repair git` without a `--prefer` side requires
268        // manual resolution — the reconcile contract's documented DataErr.
269        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        // The typed `HeddleError::RepositoryNotFound` must classify without
295        // relying on its Display text surviving a rewording.
296        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        // Corrupted msgpack state (HeddleCo/heddle#642): decode failures
306        // are data corruption, not the IoErr catch-all.
307        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    /// Build a `RecoveryAdvice` with the given kind and deliberately
350    /// unrelated copy, proving classification reads `kind`, never the
351    /// user-visible message (HeddleCo/heddle#640).
352    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        // Per-kind regression matrix: copy edits that orphan a string
368        // sentinel can no longer regress these to the IoErr catch-all.
369        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        // The real constructor's Display does not contain the legacy
396        // "dirty worktree" phrase, so only the typed kind can classify it.
397        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        // HeddleCo/heddle#648: `--output json[-compact]` against a command
423        // without that contract is semantic rejection of well-formed input
424        // (DataErr 65), not a malformed invocation (Usage 64).
425        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        // Kinds without a documented divergent code keep the catch-all, so
450        // adding a new advice kind never silently changes an exit code.
451        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}