bel7-cli 0.13.0

An opinionated set of CLI utilities: colored output, table formatting, adaptive tables, trivial to integrate shell completion
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
// Copyright (C) 2025-2026 Michael S. Klishin and Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! CLI exit code utilities.
//!
//! Provides exit code mapping following BSD sysexits conventions.

use std::error::Error;
use std::fmt;
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
use std::process;

pub use sysexits::ExitCode;

/// Extension trait for converting `ExitCode` to `i32`.
///
/// sysexits 0.11 removed direct integer conversions;
/// this trait provides the `to_i32` method for use with `process::exit`.
pub trait ExitCodeExt {
    fn to_i32(self) -> i32;
}

impl ExitCodeExt for ExitCode {
    fn to_i32(self) -> i32 {
        i32::from(u8::from(self))
    }
}

/// Trait for errors that can be mapped to CLI exit codes.
///
/// Uses BSD sysexits conventions for consistent exit code semantics.
///
/// # Example
///
/// ```
/// use bel7_cli::{ExitCodeProvider, ExitCode};
/// use thiserror::Error;
///
/// #[derive(Error, Debug)]
/// enum MyError {
///     #[error("file not found: {0}")]
///     FileNotFound(String),
///     #[error("permission denied")]
///     PermissionDenied,
///     #[error("invalid input")]
///     InvalidInput,
/// }
///
/// impl ExitCodeProvider for MyError {
///     fn exit_code(&self) -> ExitCode {
///         match self {
///             MyError::FileNotFound(_) => ExitCode::NoInput,
///             MyError::PermissionDenied => ExitCode::NoPerm,
///             MyError::InvalidInput => ExitCode::DataErr,
///         }
///     }
/// }
/// ```
pub trait ExitCodeProvider: Error {
    /// Returns the appropriate exit code for this error.
    fn exit_code(&self) -> ExitCode;
}

/// Exit code for the partial-success outcome: the program ran successfully
/// but not every unit of work completed.
///
/// Use for verbs that operate over a set (commit list, file batch, test
/// sweep) where the caller asked for "as much as possible" and the runtime
/// delivered some-but-not-all. Distinct from `ExitCode::Ok` (everything
/// succeeded) and from the `sysexits` error families (something went
/// wrong). A shell consumer that wants "did anything work?" branches on
/// `code == 0 || code == PARTIAL_SUCCESS_I32`.
///
/// `sysexits` has no slot for partial success — its codes are binary
/// success-or-error. The number sits in the small-integer band alongside
/// conventions like `diff`'s exit 1 ("differences found") and `rsync`'s
/// exit 23 ("partial transfer"), without colliding with sysexits'
/// `EX_USAGE = 64` and above.
///
/// Mirrored as [`PARTIAL_SUCCESS_U8`].
pub const PARTIAL_SUCCESS_I32: i32 = 3;

/// `u8` mirror of [`PARTIAL_SUCCESS_I32`] for consumers piping through
/// [`process::ExitCode::from(u8)`](process::ExitCode). The u8-fit is
/// enforced at compile time by the assertion below.
pub const PARTIAL_SUCCESS_U8: u8 = 3;

const _: () = assert!(
    PARTIAL_SUCCESS_I32 >= 0
        && PARTIAL_SUCCESS_I32 <= u8::MAX as i32
        && PARTIAL_SUCCESS_I32 as u8 == PARTIAL_SUCCESS_U8,
    "PARTIAL_SUCCESS_I32 must fit in u8 and agree with PARTIAL_SUCCESS_U8",
);

/// Common exit code mappings for typical error categories.
///
/// Use these as reference when implementing `ExitCodeProvider`.
pub mod codes {
    use sysexits::ExitCode;

    /// Exit code for successful completion.
    pub const OK: ExitCode = ExitCode::Ok;

    /// Exit code for partial success: ran successfully but not every
    /// unit of work completed. Re-exported as a plain `i32` because
    /// `sysexits::ExitCode` is sealed upstream and has no
    /// `PartialSuccess` variant. The canonical definition is
    /// [`super::PARTIAL_SUCCESS_I32`].
    pub const PARTIAL_SUCCESS: i32 = super::PARTIAL_SUCCESS_I32;

    /// `u8` mirror of [`PARTIAL_SUCCESS`]; canonical at
    /// [`super::PARTIAL_SUCCESS_U8`].
    pub const PARTIAL_SUCCESS_U8: u8 = super::PARTIAL_SUCCESS_U8;

    /// Exit code for general errors.
    pub const SOFTWARE: ExitCode = ExitCode::Software;

    /// Exit code for I/O errors.
    pub const IO_ERR: ExitCode = ExitCode::IoErr;

    /// Exit code for missing input files.
    pub const NO_INPUT: ExitCode = ExitCode::NoInput;

    /// Exit code for permission errors.
    pub const NO_PERM: ExitCode = ExitCode::NoPerm;

    /// Exit code for invalid user data.
    pub const DATA_ERR: ExitCode = ExitCode::DataErr;

    /// Exit code for configuration errors.
    pub const CONFIG: ExitCode = ExitCode::Config;

    /// Exit code for OS-level errors.
    pub const OS_ERR: ExitCode = ExitCode::OsErr;

    /// Exit code for unavailable services.
    pub const UNAVAILABLE: ExitCode = ExitCode::Unavailable;

    /// Exit code for temporary failures (retry may succeed).
    pub const TEMP_FAIL: ExitCode = ExitCode::TempFail;

    /// Exit code for protocol errors.
    pub const PROTOCOL: ExitCode = ExitCode::Protocol;

    /// Exit code for usage/command-line syntax errors.
    pub const USAGE: ExitCode = ExitCode::Usage;
}

/// A three-way outcome for command handlers that distinguish full
/// success, partial success, and failure.
///
/// `ExitCodeProvider` covers the binary success-or-error world via
/// [`run_with_exit_code`]. `Outcome` extends it with a third arm so a
/// verb that operates over a set can report "some-but-not-all" without
/// abusing an error variant.
#[derive(Debug)]
pub enum Outcome<E>
where
    E: ExitCodeProvider,
{
    /// Every unit of work completed.
    Success,
    /// Some units completed, others did not. Maps to
    /// [`PARTIAL_SUCCESS_I32`].
    PartialSuccess,
    /// Something went wrong. The error's `ExitCodeProvider` impl
    /// supplies the exit code.
    ///
    /// `ExitCodeProvider` implementations **must not** return
    /// `ExitCode::Ok` — doing so collapses the failure to exit `0`
    /// in [`Outcome::exit_code_i32`] and silently misclassifies it
    /// as success. The trait cannot enforce this at the type level.
    Failure(E),
}

impl<E> Outcome<E>
where
    E: ExitCodeProvider,
{
    /// The integer exit code this outcome corresponds to.
    ///
    /// Borrows rather than consuming so the caller can log the error
    /// after computing the code.
    pub fn exit_code_i32(&self) -> i32 {
        match self {
            Outcome::Success => ExitCode::Ok.to_i32(),
            Outcome::PartialSuccess => PARTIAL_SUCCESS_I32,
            Outcome::Failure(e) => e.exit_code().to_i32(),
        }
    }

    /// Convert into the `Result<u8, E>` shape that consumers using
    /// `process::ExitCode::from(u8)` need.
    ///
    /// `Success` and `PartialSuccess` collapse to their `Ok(u8)` codes;
    /// `Failure(e)` propagates the error unchanged so the caller can
    /// log a `caused by:` chain before exiting.
    pub fn into_result_u8(self) -> Result<u8, E> {
        match self {
            Outcome::Success => Ok(0),
            Outcome::PartialSuccess => Ok(PARTIAL_SUCCESS_U8),
            Outcome::Failure(e) => Err(e),
        }
    }

    pub fn is_success(&self) -> bool {
        matches!(self, Outcome::Success)
    }

    pub fn is_partial_success(&self) -> bool {
        matches!(self, Outcome::PartialSuccess)
    }

    pub fn is_failure(&self) -> bool {
        matches!(self, Outcome::Failure(_))
    }
}

impl<E> fmt::Display for Outcome<E>
where
    E: ExitCodeProvider + fmt::Display,
{
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Outcome::Success => write!(f, "success (exit 0)"),
            Outcome::PartialSuccess => {
                write!(f, "partial success (exit {PARTIAL_SUCCESS_I32})")
            }
            Outcome::Failure(e) => {
                write!(f, "failure (exit {}): {e}", e.exit_code().to_i32())
            }
        }
    }
}

/// Helper function to run a main function and exit with appropriate code.
///
/// # Example
///
/// ```ignore
/// fn main() {
///     bel7_cli::run_with_exit_code(real_main);
/// }
///
/// fn real_main() -> Result<(), MyError> {
///     // ... your logic
///     Ok(())
/// }
/// ```
pub fn run_with_exit_code<E, F>(f: F) -> !
where
    E: ExitCodeProvider,
    F: FnOnce() -> Result<(), E>,
{
    match f() {
        Ok(()) => process::exit(ExitCode::Ok.to_i32()),
        Err(e) => {
            eprintln!("Error: {e}");
            process::exit(e.exit_code().to_i32())
        }
    }
}

/// Sibling of [`run_with_exit_code`] for handlers that distinguish
/// partial success.
///
/// On [`Outcome::PartialSuccess`] exits with [`PARTIAL_SUCCESS_I32`].
/// On [`Outcome::Failure`] prints the error to stderr and exits with
/// its `ExitCodeProvider` code. On [`Outcome::Success`] exits zero.
pub fn run_with_outcome<E, F>(f: F) -> !
where
    E: ExitCodeProvider,
    F: FnOnce() -> Outcome<E>,
{
    let outcome = f();
    if let Outcome::Failure(e) = &outcome {
        eprintln!("Error: {e}");
    }
    process::exit(outcome.exit_code_i32())
}

/// Consumer-side mirror of [`Outcome`] for Rust programs that read another
/// process's exit code (subprocess drivers, CI gates, batch wrappers).
///
/// [`Outcome`] is what a CLI verb *produces*; `ExitOutcome` is what a
/// downstream Rust caller *observes*. Both share the same constants
/// ([`PARTIAL_SUCCESS_I32`], `0`) so an ecosystem CLI that adopts
/// [`Outcome`] is automatically consumable through `ExitOutcome` without
/// per-consumer code.
///
/// Unlike [`Outcome`], the `Failure` arm carries the raw `i32` exit code
/// rather than a typed `E: ExitCodeProvider`, because the consumer
/// observes integers from another process — there is no typed error to
/// round-trip. Callers that need to branch on sysexits values (`64`
/// usage, `65` data, `75` temp-fail) read the integer directly out of
/// `Failure(i32)`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ExitOutcome {
    /// The subprocess exited `0`.
    Success,
    /// The subprocess exited [`PARTIAL_SUCCESS_I32`]. The caller should
    /// treat its stdout as a parseable payload (same as `Success`) but
    /// note that not every unit of work completed.
    PartialSuccess,
    /// The subprocess exited with any other code, or (on Unix) was
    /// killed by a signal. [`from_status`](Self::from_status) encodes
    /// Unix signal kills as `Failure(128 + signum)` — the shell
    /// convention — so `SIGTERM` (`143`) stays distinguishable from
    /// `SIGINT` (`130`) and from a literal `exit(143)`. A signal-less
    /// `None` status (rare) falls back to `Failure(-1)`.
    ///
    /// **Invariant.** Values constructed via
    /// [`from_code`](Self::from_code) or
    /// [`from_status`](Self::from_status) always carry an integer
    /// that is `!= 0 && != PARTIAL_SUCCESS_I32`. Manual construction
    /// (e.g. `Failure(0)`) bypasses the invariant; use
    /// [`canonicalize`](Self::canonicalize) to restore it.
    Failure(i32),
}

impl ExitOutcome {
    /// Classify a raw exit code. The primitive constructor —
    /// [`ExitOutcome::from_status`] and any other entry point reduce to
    /// this.
    pub fn from_code(code: i32) -> Self {
        match code {
            0 => Self::Success,
            c if c == PARTIAL_SUCCESS_I32 => Self::PartialSuccess,
            other => Self::Failure(other),
        }
    }

    /// Classify a [`process::ExitStatus`] from a child subprocess.
    ///
    /// On Unix, a signal kill (`status.code()` is `None`) maps to
    /// `Failure(128 + signum)` — the shell convention that keeps
    /// `SIGTERM` (143) distinguishable from `SIGINT` (130) and from a
    /// literal `exit(143)`. Unknown signal-less statuses fall back to
    /// `Failure(-1)`. On Windows, `status.code()` is always `Some`,
    /// so the helper just defers to [`Self::from_code`].
    pub fn from_status(status: &process::ExitStatus) -> Self {
        if let Some(code) = status.code() {
            return Self::from_code(code);
        }
        #[cfg(unix)]
        if let Some(signum) = ExitStatusExt::signal(status) {
            return Self::Failure(128 + signum);
        }
        Self::Failure(-1)
    }

    /// `true` for [`ExitOutcome::Success`] and
    /// [`ExitOutcome::PartialSuccess`] — the check a subprocess driver
    /// reaches for to decide "should I parse stdout as a payload?".
    pub fn is_success_shaped(&self) -> bool {
        matches!(self, Self::Success | Self::PartialSuccess)
    }

    /// `true` iff this is [`ExitOutcome::Success`].
    pub fn is_success(&self) -> bool {
        matches!(self, Self::Success)
    }

    /// `true` iff this is [`ExitOutcome::PartialSuccess`].
    pub fn is_partial_success(&self) -> bool {
        matches!(self, Self::PartialSuccess)
    }

    /// `true` iff this is [`ExitOutcome::Failure`].
    pub fn is_failure(&self) -> bool {
        matches!(self, Self::Failure(_))
    }

    /// The exit code this outcome corresponds to. Round-trip:
    /// `ExitOutcome::from_code(n).code() == n` for every `i32`.
    pub fn code(&self) -> i32 {
        match self {
            Self::Success => 0,
            Self::PartialSuccess => PARTIAL_SUCCESS_I32,
            Self::Failure(c) => *c,
        }
    }

    /// Collapse a manually-constructed non-canonical state into its
    /// canonical arm. `Failure(0)` becomes `Success`,
    /// `Failure(PARTIAL_SUCCESS_I32)` becomes `PartialSuccess`,
    /// everything else is returned unchanged.
    ///
    /// Useful when an `ExitOutcome` is built from an integer field
    /// (e.g. a JSON envelope's `exit_code`) directly into the
    /// `Failure` variant; calling `canonicalize` afterwards restores
    /// the invariant [`from_code`](Self::from_code) maintains.
    pub fn canonicalize(self) -> Self {
        match self {
            Self::Failure(c) => Self::from_code(c),
            other => other,
        }
    }
}

impl fmt::Display for ExitOutcome {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Success => write!(f, "success (exit 0)"),
            Self::PartialSuccess => {
                write!(f, "partial success (exit {PARTIAL_SUCCESS_I32})")
            }
            Self::Failure(c) => write!(f, "failure (exit {c})"),
        }
    }
}