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_commit"
113            | "reconcile_direction_required"
114            | "git_repair_direction_required"
115            | "dirty_worktree"
116            | "state_corrupted"
117            | "state_not_found"
118            | "conflict_not_found"
119            | "no_merge_in_progress"
120            | "operation_not_in_progress"
121            | "json_unsupported"
122            | "json_compact_unsupported"
123            // Operator/land/ready/continue finished rendering a blocked or
124            // failed envelope — semantic rejection of well-formed input.
125            | "operator_blocked"
126            // Hosted merge eligibility gate refused the pair.
127            | "merge_eligibility_blocked" => Some(Self::DataErr),
128            // Capture aborted on ENOSPC; working tree is intact. Classifies
129            // as IO rather than a distinct raw-28 OS code so agents stay on
130            // the documented sysexits taxonomy.
131            "capture_out_of_space" => Some(Self::IoErr),
132            _ => None,
133        }
134    }
135
136    /// True when the error is a post-render outcome that must not print a
137    /// second stderr envelope (the command body already wrote the report).
138    pub fn is_quiet_outcome(err: &anyhow::Error) -> bool {
139        err.chain().any(|cause| cause.is::<OutcomeExit>())
140    }
141
142    /// Map an anyhow error chain to an exit code. Walks the chain and uses
143    /// the first downcast match; falls back to `IoErr` so callers always
144    /// get a code more informative than the bare `1` shell convention.
145    pub fn from_error(err: &anyhow::Error) -> Self {
146        for cause in err.chain() {
147            // Already-rendered command outcomes carry an explicit code and
148            // must not fall through to the IoErr catch-all.
149            if let Some(outcome) = cause.downcast_ref::<OutcomeExit>() {
150                return outcome.code();
151            }
152            // Typed refusals carry a stable `kind` discriminator — route the
153            // ones whose documented code differs from the `IoErr` catch-all.
154            // Keyed on `kind` (not the user-visible message) so rewording the
155            // error text can't silently regress the contract.
156            if let Some(advice) = cause.downcast_ref::<crate::cli::commands::RecoveryAdvice>()
157                && let Some(code) = Self::for_advice_kind(advice.kind)
158            {
159                return code;
160            }
161            if let Some(heddle_err) = cause.downcast_ref::<objects::error::HeddleError>() {
162                match heddle_err {
163                    objects::error::HeddleError::Recovery(details) => {
164                        if let Some(code) = Self::for_advice_kind(details.kind) {
165                            return code;
166                        }
167                    }
168                    // A missing repository is a missing precondition
169                    // (initialize/point at one), not an IO failure.
170                    objects::error::HeddleError::RepositoryNotFound(_) => return Self::Config,
171                    objects::error::HeddleError::RepositoryFormatTooNew { .. } => {
172                        return Self::DataErr;
173                    }
174                    objects::error::HeddleError::StateNotFound(_)
175                    | objects::error::HeddleError::NoMergeInProgress
176                    | objects::error::HeddleError::ConfigInvalidValue { .. } => {
177                        return Self::DataErr;
178                    }
179                    objects::error::HeddleError::Config(_) => return Self::Config,
180                    // Stored state that fails msgpack decoding is corrupted
181                    // data, not a transient IO problem — same class as the
182                    // serde_json/toml parse failures below.
183                    objects::error::HeddleError::Serialization(_) => return Self::DataErr,
184                    _ => {}
185                }
186            }
187            if let Some(remote_err) = cause.downcast_ref::<crate::remote::RemoteError>()
188                && matches!(
189                    remote_err,
190                    crate::remote::RemoteError::NotFound(_)
191                        | crate::remote::RemoteError::NoDefaultRemote
192                )
193            {
194                return Self::Config;
195            }
196            if let Some(io) = cause.downcast_ref::<std::io::Error>() {
197                return match io.kind() {
198                    IoErrorKind::PermissionDenied => Self::NoPerm,
199                    IoErrorKind::TimedOut
200                    | IoErrorKind::ConnectionRefused
201                    | IoErrorKind::ConnectionAborted
202                    | IoErrorKind::ConnectionReset
203                    | IoErrorKind::Interrupted => Self::TempFail,
204                    IoErrorKind::NotFound | IoErrorKind::AlreadyExists => Self::CantCreat,
205                    _ => Self::IoErr,
206                };
207            }
208            if let Some(status) = cause.downcast_ref::<tonic::Status>() {
209                use tonic::Code;
210                return match status.code() {
211                    Code::Unavailable | Code::DeadlineExceeded | Code::ResourceExhausted => {
212                        Self::TempFail
213                    }
214                    Code::InvalidArgument | Code::FailedPrecondition | Code::OutOfRange => {
215                        Self::Protocol
216                    }
217                    Code::PermissionDenied | Code::Unauthenticated => Self::NoPerm,
218                    Code::NotFound => Self::Config,
219                    _ => Self::IoErr,
220                };
221            }
222            if cause.is::<serde_json::Error>() || cause.is::<toml::de::Error>() {
223                return Self::DataErr;
224            }
225        }
226
227        Self::IoErr
228    }
229
230    pub fn as_u8(self) -> u8 {
231        self as u8
232    }
233}
234
235impl From<HeddleExitCode> for i32 {
236    fn from(code: HeddleExitCode) -> Self {
237        code as i32
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn io_permission_denied_maps_to_noperm() {
247        let err: anyhow::Error =
248            std::io::Error::new(IoErrorKind::PermissionDenied, "denied").into();
249        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::NoPerm);
250    }
251
252    #[test]
253    fn io_timed_out_is_retry_safe() {
254        let err: anyhow::Error = std::io::Error::new(IoErrorKind::TimedOut, "slow").into();
255        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::TempFail);
256    }
257
258    #[test]
259    fn config_parse_preserves_toml_source_as_data_err() {
260        // Regression for Codex R4 (cid 3315305484): `ConfigParse` must keep
261        // the `toml::de::Error` as its source so the chain-walk still
262        // classifies it, rather than flattening to a String and falling
263        // through to `IoErr`.
264        let toml_err = toml::from_str::<toml::Value>("= nope").unwrap_err();
265        let err: anyhow::Error = objects::error::HeddleError::ConfigParse {
266            path: std::path::PathBuf::from("/tmp/config.toml"),
267            source: toml_err,
268        }
269        .into();
270        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
271    }
272
273    #[test]
274    fn serde_json_is_data_err() {
275        let err: anyhow::Error = serde_json::from_str::<serde_json::Value>("{")
276            .unwrap_err()
277            .into();
278        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
279    }
280
281    #[test]
282    fn remote_error_no_default_remote_is_config() {
283        let err = anyhow::anyhow!(crate::remote::RemoteError::NoDefaultRemote);
284        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
285    }
286
287    #[test]
288    fn heddle_config_error_is_config() {
289        let err: anyhow::Error =
290            objects::error::HeddleError::Config("workspace config invalid".to_string()).into();
291        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
292    }
293
294    #[test]
295    fn remote_not_configured_advice_is_config() {
296        // `heddle push`/`heddle pull` with no default remote raise the typed
297        // `remote_not_configured` advice — a missing-precondition (Config),
298        // not the `IoErr` catch-all.
299        let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::remote_not_configured(
300            "push"
301        ));
302        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
303    }
304
305    #[test]
306    fn nothing_to_commit_advice_is_data_err() {
307        // `heddle commit` with nothing staged is semantic rejection of
308        // well-formed input (DataErr), not an IO failure.
309        let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
310            "nothing_to_commit",
311            "nothing to commit",
312            "hint",
313            "unsafe",
314            "would change",
315            "preserved",
316            "heddle status",
317            vec!["heddle status".to_string()],
318        );
319        let err = anyhow::anyhow!(advice);
320        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
321    }
322
323    #[test]
324    fn reconcile_direction_required_advice_is_data_err() {
325        // `heddle fsck --repair git` without a `--prefer` side requires
326        // manual resolution — the reconcile contract's documented DataErr.
327        let advice = crate::cli::commands::RecoveryAdvice::safety_refusal(
328            "reconcile_direction_required",
329            "Refusing to reconcile 'main': choose a local side before applying",
330            "hint",
331            "unsafe",
332            "would change",
333            "preserved",
334            "heddle status",
335            vec!["heddle status".to_string()],
336        );
337        let err = anyhow::anyhow!(advice);
338        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
339    }
340
341    #[test]
342    fn repository_not_found_recovery_details_are_config() {
343        let err: anyhow::Error = objects::error::HeddleError::recovery(
344            objects::RecoveryDetails::repository_not_found(std::path::Path::new("/tmp/whatever")),
345        )
346        .into();
347        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
348    }
349
350    #[test]
351    fn repository_not_found_typed_variant_is_config() {
352        // The typed `HeddleError::RepositoryNotFound` must classify without
353        // relying on its Display text surviving a rewording.
354        let err: anyhow::Error = objects::error::HeddleError::RepositoryNotFound(
355            std::path::PathBuf::from("/tmp/whatever"),
356        )
357        .into();
358        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::Config);
359    }
360
361    #[test]
362    fn serialization_error_typed_variant_is_data_err() {
363        // Corrupted msgpack state (HeddleCo/heddle#642): decode failures
364        // are data corruption, not the IoErr catch-all.
365        let err: anyhow::Error = objects::error::HeddleError::Serialization(
366            "wrong msgpack marker FixArray(0)".to_string(),
367        )
368        .into();
369        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
370    }
371
372    #[test]
373    fn state_not_found_typed_variant_is_data_err() {
374        let err: anyhow::Error =
375            objects::error::HeddleError::StateNotFound(objects::object::ChangeId::generate())
376                .into();
377        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
378    }
379
380    #[test]
381    fn invalid_config_value_typed_variant_is_data_err() {
382        let err: anyhow::Error = objects::error::HeddleError::ConfigInvalidValue {
383            path: std::path::PathBuf::from("/tmp/config.toml"),
384            key: "output.format".to_string(),
385            value: "auto".to_string(),
386            valid_values: vec!["'text'".to_string(), "'json'".to_string()],
387        }
388        .into();
389        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
390    }
391
392    #[test]
393    fn no_merge_in_progress_typed_variant_is_data_err() {
394        let err: anyhow::Error = objects::error::HeddleError::NoMergeInProgress.into();
395        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
396    }
397
398    #[test]
399    fn recovery_details_kind_uses_advice_exit_code_mapping() {
400        let err: anyhow::Error = objects::error::HeddleError::recovery(
401            objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
402        )
403        .into();
404        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
405    }
406
407    /// Build a `RecoveryAdvice` with the given kind and deliberately
408    /// unrelated copy, proving classification reads `kind`, never the
409    /// user-visible message (HeddleCo/heddle#640).
410    fn advice_with_kind(kind: &'static str) -> anyhow::Error {
411        anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::safety_refusal(
412            kind,
413            "reworded copy that matches no sentinel",
414            "hint",
415            "unsafe",
416            "would change",
417            "preserved",
418            "heddle status",
419            vec!["heddle status".to_string()],
420        ))
421    }
422
423    #[test]
424    fn every_classified_advice_kind_maps_to_its_documented_exit_code() {
425        // Per-kind regression matrix: copy edits that orphan a string
426        // sentinel can no longer regress these to the IoErr catch-all.
427        for (kind, expected) in [
428            ("remote_not_configured", HeddleExitCode::Config),
429            ("remote_not_found", HeddleExitCode::Config),
430            ("repository_not_found", HeddleExitCode::Config),
431            ("nothing_to_commit", HeddleExitCode::DataErr),
432            ("reconcile_direction_required", HeddleExitCode::DataErr),
433            ("git_repair_direction_required", HeddleExitCode::DataErr),
434            ("dirty_worktree", HeddleExitCode::DataErr),
435            ("state_corrupted", HeddleExitCode::DataErr),
436            ("state_not_found", HeddleExitCode::DataErr),
437            ("no_merge_in_progress", HeddleExitCode::DataErr),
438            ("operation_not_in_progress", HeddleExitCode::DataErr),
439            ("conflict_not_found", HeddleExitCode::DataErr),
440            ("json_unsupported", HeddleExitCode::DataErr),
441            ("json_compact_unsupported", HeddleExitCode::DataErr),
442            ("operator_blocked", HeddleExitCode::DataErr),
443            ("merge_eligibility_blocked", HeddleExitCode::DataErr),
444            ("capture_out_of_space", HeddleExitCode::IoErr),
445        ] {
446            assert_eq!(
447                HeddleExitCode::from_error(&advice_with_kind(kind)),
448                expected,
449                "advice kind `{kind}` must classify by kind, not message text"
450            );
451        }
452    }
453
454    #[test]
455    fn dirty_worktree_advice_constructor_is_data_err() {
456        // The real constructor's Display does not contain the legacy
457        // "dirty worktree" phrase, so only the typed kind can classify it.
458        let err = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::dirty_worktree(
459            "merge",
460            vec!["src/lib.rs".to_string()],
461            "repository state was left unchanged",
462        ));
463        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
464    }
465
466    #[test]
467    fn dirty_worktree_recovery_details_are_data_err() {
468        let err: anyhow::Error =
469            objects::error::HeddleError::recovery(objects::RecoveryDetails::safety_refusal(
470                "dirty_worktree",
471                "reworded copy that matches no sentinel",
472                "hint",
473                "unsafe",
474                "would change",
475                "preserved",
476            ))
477            .into();
478        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
479    }
480
481    #[test]
482    fn unsupported_output_advice_is_data_err() {
483        // HeddleCo/heddle#648: `--output json[-compact]` against a command
484        // without that contract is semantic rejection of well-formed input
485        // (DataErr 65), not a malformed invocation (Usage 64).
486        let json = anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_unsupported(
487            "shell completion"
488        ));
489        assert_eq!(HeddleExitCode::from_error(&json), HeddleExitCode::DataErr);
490
491        let compact =
492            anyhow::anyhow!(crate::cli::commands::RecoveryAdvice::json_compact_unsupported("log"));
493        assert_eq!(
494            HeddleExitCode::from_error(&compact),
495            HeddleExitCode::DataErr
496        );
497    }
498
499    #[test]
500    fn state_corrupted_recovery_details_are_data_err() {
501        let err: anyhow::Error = objects::error::HeddleError::recovery(
502            objects::RecoveryDetails::serialization_error("wrong msgpack marker FixArray(0)"),
503        )
504        .into();
505        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
506    }
507
508    #[test]
509    fn unclassified_advice_kind_falls_back_to_io_err() {
510        // Kinds without a documented divergent code keep the catch-all, so
511        // adding a new advice kind never silently changes an exit code.
512        assert_eq!(
513            HeddleExitCode::from_error(&advice_with_kind("hook_veto")),
514            HeddleExitCode::IoErr
515        );
516    }
517
518    #[test]
519    fn outcome_exit_maps_to_its_code() {
520        let err = anyhow::anyhow!(OutcomeExit::data_err());
521        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::DataErr);
522        assert!(HeddleExitCode::is_quiet_outcome(&err));
523    }
524
525    #[test]
526    fn capture_out_of_space_advice_is_io_err() {
527        assert_eq!(
528            HeddleExitCode::from_error(&advice_with_kind("capture_out_of_space")),
529            HeddleExitCode::IoErr
530        );
531    }
532
533    #[test]
534    fn operator_blocked_advice_is_data_err() {
535        assert_eq!(
536            HeddleExitCode::from_error(&advice_with_kind("operator_blocked")),
537            HeddleExitCode::DataErr
538        );
539        assert_eq!(
540            HeddleExitCode::from_error(&advice_with_kind("merge_eligibility_blocked")),
541            HeddleExitCode::DataErr
542        );
543    }
544
545    #[test]
546    fn unknown_falls_back_to_io_err() {
547        let err = anyhow::anyhow!("some unrelated thing went wrong");
548        assert_eq!(HeddleExitCode::from_error(&err), HeddleExitCode::IoErr);
549    }
550
551    #[test]
552    fn u8_repr_matches_sysexits() {
553        assert_eq!(HeddleExitCode::Ok.as_u8(), 0);
554        assert_eq!(HeddleExitCode::Usage.as_u8(), 64);
555        assert_eq!(HeddleExitCode::DataErr.as_u8(), 65);
556        assert_eq!(HeddleExitCode::CantCreat.as_u8(), 73);
557        assert_eq!(HeddleExitCode::IoErr.as_u8(), 74);
558        assert_eq!(HeddleExitCode::TempFail.as_u8(), 75);
559        assert_eq!(HeddleExitCode::Protocol.as_u8(), 76);
560        assert_eq!(HeddleExitCode::NoPerm.as_u8(), 77);
561        assert_eq!(HeddleExitCode::Config.as_u8(), 78);
562    }
563}