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" => Some(Self::Config),
64            // Well-formed input the command semantically rejects:
65            // - nothing staged / no changes to capture
66            // - a reconcile that needs a `--prefer` side
67            // - unsaved worktree changes blocking a tree write
68            // - repository state that fails msgpack/serde decoding
69            // - `--output json`/`json-compact` against a command without
70            //   that output contract (the invocation parses fine; the
71            //   command rejects the requested projection)
72            "nothing_to_commit"
73            | "reconcile_direction_required"
74            | "dirty_worktree"
75            | "state_corrupted"
76            | "conflict_not_found"
77            | "json_unsupported"
78            | "json_compact_unsupported" => Some(Self::DataErr),
79            _ => None,
80        }
81    }
82
83    /// Map an anyhow error chain to an exit code. Walks the chain and uses
84    /// the first downcast match; falls back to `IoErr` so callers always
85    /// get a code more informative than the bare `1` shell convention.
86    pub fn from_error(err: &anyhow::Error) -> Self {
87        for cause in err.chain() {
88            // Typed refusals carry a stable `kind` discriminator — route the
89            // ones whose documented code differs from the `IoErr` catch-all.
90            // Keyed on `kind` (not the user-visible message) so rewording the
91            // error text can't silently regress the contract.
92            if let Some(advice) = cause.downcast_ref::<crate::cli::commands::RecoveryAdvice>()
93                && let Some(code) = Self::for_advice_kind(advice.kind)
94            {
95                return code;
96            }
97            if let Some(heddle_err) = cause.downcast_ref::<objects::error::HeddleError>() {
98                match heddle_err {
99                    // A missing repository is a missing precondition
100                    // (initialize/point at one), not an IO failure.
101                    objects::error::HeddleError::RepositoryNotFound(_) => return Self::Config,
102                    objects::error::HeddleError::RepositoryFormatTooNew { .. } => {
103                        return Self::DataErr;
104                    }
105                    // Stored state that fails msgpack decoding is corrupted
106                    // data, not a transient IO problem — same class as the
107                    // serde_json/toml parse failures below.
108                    objects::error::HeddleError::Serialization(_) => return Self::DataErr,
109                    _ => {}
110                }
111            }
112            if let Some(io) = cause.downcast_ref::<std::io::Error>() {
113                return match io.kind() {
114                    IoErrorKind::PermissionDenied => Self::NoPerm,
115                    IoErrorKind::TimedOut
116                    | IoErrorKind::ConnectionRefused
117                    | IoErrorKind::ConnectionAborted
118                    | IoErrorKind::ConnectionReset
119                    | IoErrorKind::Interrupted => Self::TempFail,
120                    IoErrorKind::NotFound | IoErrorKind::AlreadyExists => Self::CantCreat,
121                    _ => Self::IoErr,
122                };
123            }
124            if let Some(status) = cause.downcast_ref::<tonic::Status>() {
125                use tonic::Code;
126                return match status.code() {
127                    Code::Unavailable | Code::DeadlineExceeded | Code::ResourceExhausted => {
128                        Self::TempFail
129                    }
130                    Code::InvalidArgument | Code::FailedPrecondition | Code::OutOfRange => {
131                        Self::Protocol
132                    }
133                    Code::PermissionDenied | Code::Unauthenticated => Self::NoPerm,
134                    Code::NotFound => Self::Config,
135                    _ => Self::IoErr,
136                };
137            }
138            if cause.is::<serde_json::Error>() || cause.is::<toml::de::Error>() {
139                return Self::DataErr;
140            }
141        }
142
143        // Legacy string sentinels — LAST RESORT for raw-string error paths
144        // that carry no typed `RecoveryAdvice` or `HeddleError` (e.g. the
145        // stringified `RemoteError::NotFound` display from `resolve_remote`,
146        // or upstream messages flattened through `anyhow::Error::msg`).
147        // Any error that has a typed kind MUST be classified above via
148        // `for_advice_kind` / the `HeddleError` match — never add a sentinel
149        // here for a message a typed constructor produces. Keep these short
150        // and exact so they don't false-positive on unrelated messages.
151        let msg = format!("{err:#}");
152        if msg.contains("no upstream configured")
153            || msg.contains("no remote configured")
154            || msg.contains("no default remote configured")
155            || msg.contains("workspace config invalid")
156            || msg.contains("repository not found")
157        {
158            return Self::Config;
159        }
160        if msg.contains("dirty worktree") {
161            return Self::DataErr;
162        }
163
164        Self::IoErr
165    }
166
167    pub fn as_u8(self) -> u8 {
168        self as u8
169    }
170}
171
172impl From<HeddleExitCode> for i32 {
173    fn from(code: HeddleExitCode) -> Self {
174        code as i32
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn io_permission_denied_maps_to_noperm() {
184        let err: anyhow::Error =
185            std::io::Error::new(IoErrorKind::PermissionDenied, "denied").into();
186        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::NoPerm);
187    }
188
189    #[test]
190    fn io_timed_out_is_retry_safe() {
191        let err: anyhow::Error = std::io::Error::new(IoErrorKind::TimedOut, "slow").into();
192        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::TempFail);
193    }
194
195    #[test]
196    fn config_parse_preserves_toml_source_as_data_err() {
197        // Regression for Codex R4 (cid 3315305484): `ConfigParse` must keep
198        // the `toml::de::Error` as its source so the chain-walk still
199        // classifies it, rather than flattening to a String and falling
200        // through to `IoErr`.
201        let toml_err = toml::from_str::<toml::Value>("= nope").unwrap_err();
202        let err: anyhow::Error = objects::error::HeddleError::ConfigParse {
203            path: std::path::PathBuf::from("/tmp/config.toml"),
204            source: toml_err,
205        }
206        .into();
207        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
208    }
209
210    #[test]
211    fn serde_json_is_data_err() {
212        let err: anyhow::Error = serde_json::from_str::<serde_json::Value>("{")
213            .unwrap_err()
214            .into();
215        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
216    }
217
218    #[test]
219    fn no_upstream_string_sentinel_is_config() {
220        let err = anyhow::anyhow!("push refused: no upstream configured for branch 'main'");
221        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
222    }
223
224    #[test]
225    fn no_default_remote_string_sentinel_is_config() {
226        // `heddle pull` against a repo with no default remote surfaces the
227        // raw `RemoteError::NotFound` display via `anyhow::Error::msg`, so it
228        // is only matchable as a string. The persona-flagged divergence
229        // (HeddleCo/heddle#252) was this returning the `IoErr` catch-all.
230        let err = anyhow::anyhow!("remote not found: (no default remote configured)");
231        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
232    }
233
234    #[test]
235    fn remote_not_configured_advice_is_config() {
236        // `heddle push`/`heddle pull` with no default remote raise the typed
237        // `remote_not_configured` advice — a missing-precondition (Config),
238        // not the `IoErr` catch-all.
239        let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::remote_not_configured(
240            "push"
241        ));
242        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
243    }
244
245    #[test]
246    fn nothing_to_commit_advice_is_data_err() {
247        // `heddle commit` with nothing staged is semantic rejection of
248        // well-formed input (DataErr), not an IO failure.
249        let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
250            "nothing_to_commit",
251            "nothing to commit",
252            "hint",
253            "unsafe",
254            "would change",
255            "preserved",
256            "heddle status",
257            vec!["heddle status".to_string()],
258        );
259        let err = anyhow::anyhow!(advice);
260        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
261    }
262
263    #[test]
264    fn reconcile_direction_required_advice_is_data_err() {
265        // `heddle bridge git reconcile` without a `--prefer` side requires
266        // manual resolution — the reconcile contract's documented DataErr.
267        let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
268            "reconcile_direction_required",
269            "Refusing to reconcile 'main': choose a local side before applying",
270            "hint",
271            "unsafe",
272            "would change",
273            "preserved",
274            "heddle status",
275            vec!["heddle status".to_string()],
276        );
277        let err = anyhow::anyhow!(advice);
278        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
279    }
280
281    #[test]
282    fn missing_repo_string_sentinel_is_config() {
283        let err = anyhow::anyhow!("repository not found at /tmp/whatever");
284        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
285    }
286
287    #[test]
288    fn repository_not_found_typed_variant_is_config() {
289        // The typed `HeddleError::RepositoryNotFound` must classify without
290        // relying on its Display text surviving a rewording.
291        let err: anyhow::Error = objects::error::HeddleError::RepositoryNotFound(
292            std::path::PathBuf::from("/tmp/whatever"),
293        )
294        .into();
295        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
296    }
297
298    #[test]
299    fn serialization_error_typed_variant_is_data_err() {
300        // Corrupted msgpack state (HeddleCo/heddle#642): decode failures
301        // are data corruption, not the IoErr catch-all.
302        let err: anyhow::Error = objects::error::HeddleError::Serialization(
303            "wrong msgpack marker FixArray(0)".to_string(),
304        )
305        .into();
306        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
307    }
308
309    /// Build a `RecoveryAdvice` with the given kind and deliberately
310    /// unrelated copy, proving classification reads `kind`, never the
311    /// user-visible message (HeddleCo/heddle#640).
312    fn advice_with_kind(kind: &'static str) -> anyhow::Error {
313        anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::safety_refusal(
314            kind,
315            "reworded copy that matches no sentinel",
316            "hint",
317            "unsafe",
318            "would change",
319            "preserved",
320            "heddle status",
321            vec!["heddle status".to_string()],
322        ))
323    }
324
325    #[test]
326    fn every_classified_advice_kind_maps_to_its_documented_exit_code() {
327        // Per-kind regression matrix: copy edits that orphan a string
328        // sentinel can no longer regress these to the IoErr catch-all.
329        for (kind, expected) in [
330            ("remote_not_configured", HeddleExitCode::Config),
331            ("nothing_to_commit", HeddleExitCode::DataErr),
332            ("reconcile_direction_required", HeddleExitCode::DataErr),
333            ("dirty_worktree", HeddleExitCode::DataErr),
334            ("state_corrupted", HeddleExitCode::DataErr),
335            ("conflict_not_found", HeddleExitCode::DataErr),
336            ("json_unsupported", HeddleExitCode::DataErr),
337            ("json_compact_unsupported", HeddleExitCode::DataErr),
338        ] {
339            assert_eq!(
340                HeddleExitCode::from_error(&advice_with_kind(kind)),
341                expected,
342                "advice kind `{kind}` must classify by kind, not message text"
343            );
344        }
345    }
346
347    #[test]
348    fn dirty_worktree_advice_constructor_is_data_err() {
349        // The real constructor's Display does not contain the legacy
350        // "dirty worktree" sentinel, so only the typed kind can classify it.
351        let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::dirty_worktree(
352            "merge",
353            vec!["src/lib.rs".to_string()],
354            "repository state was left unchanged",
355        ));
356        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
357    }
358
359    #[test]
360    fn dirty_worktree_string_sentinel_is_data_err() {
361        // Raw-string path (e.g. repository_worktree_apply's refusal) that
362        // carries no typed advice still classifies via the legacy sentinel.
363        let err =
364            anyhow::anyhow!("dirty worktree would be overwritten by full rematerialize (switch)");
365        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
366    }
367
368    #[test]
369    fn unsupported_output_advice_is_data_err() {
370        // HeddleCo/heddle#648: `--output json[-compact]` against a command
371        // without that contract is semantic rejection of well-formed input
372        // (DataErr 65), not a malformed invocation (Usage 64).
373        let json = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_unsupported(
374            "shell completion"
375        ));
376        assert_eq!(HeddleExitCode::from_error(&json), HeddleExitCode::DataErr);
377
378        let compact =
379            anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_compact_unsupported("log"));
380        assert_eq!(
381            HeddleExitCode::from_error(&compact),
382            HeddleExitCode::DataErr
383        );
384    }
385
386    #[test]
387    fn state_corrupted_advice_is_data_err() {
388        let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::serialization_error(
389            "wrong msgpack marker FixArray(0)"
390        ));
391        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
392    }
393
394    #[test]
395    fn unclassified_advice_kind_falls_back_to_io_err() {
396        // Kinds without a documented divergent code keep the catch-all, so
397        // adding a new advice kind never silently changes an exit code.
398        assert_eq!(
399            HeddleExitCode::from_error(&advice_with_kind("hook_veto")),
400            HeddleExitCode::IoErr
401        );
402    }
403
404    #[test]
405    fn unknown_falls_back_to_io_err() {
406        let err = anyhow::anyhow!("some unrelated thing went wrong");
407        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::IoErr);
408    }
409
410    #[test]
411    fn u8_repr_matches_sysexits() {
412        assert_eq!(HeddleExitCode::Ok.as_u8(), 0);
413        assert_eq!(HeddleExitCode::Usage.as_u8(), 64);
414        assert_eq!(HeddleExitCode::DataErr.as_u8(), 65);
415        assert_eq!(HeddleExitCode::CantCreat.as_u8(), 73);
416        assert_eq!(HeddleExitCode::IoErr.as_u8(), 74);
417        assert_eq!(HeddleExitCode::TempFail.as_u8(), 75);
418        assert_eq!(HeddleExitCode::Protocol.as_u8(), 76);
419        assert_eq!(HeddleExitCode::NoPerm.as_u8(), 77);
420        assert_eq!(HeddleExitCode::Config.as_u8(), 78);
421    }
422}