Skip to main content

browser_automation_cli/
error.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Typed CLI errors with sysexits-style exit codes.
3//!
4//! # Error kinds
5//!
6//! [`ErrorKind`](crate::error::ErrorKind) maps to process exit codes used by the
7//! binary and JSON envelopes. Stable machine strings come from
8//! [`ErrorKind::as_str`](crate::error::ErrorKind::as_str).
9//!
10//! # Conversions
11//!
12//! - [`CliError::exit_code`](crate::error::CliError::exit_code) /
13//!   [`ErrorKind::exit_code`](crate::error::ErrorKind::exit_code) — infallible
14//!   `u8` mapping (zero-cost)
15//! - [`ErrorKind::as_str`](crate::error::ErrorKind::as_str) — static
16//!   `&'static str` for JSON `error.kind` (zero-cost)
17//! - Prefer these helpers over ad-hoc `as` casts when mapping to process status
18//! - There is no fallible `TryFrom` path: every kind has a fixed exit code
19//!
20//! # Cost
21//!
22//! | Operation | Cost |
23//! |-----------|------|
24//! | `exit_code()` | O(1), no allocation |
25//! | `as_str()` | O(1), static slice |
26//! | `CliError::new` / `with_suggestion` | allocates `String` message (and optional suggestion) |
27//!
28//! # Examples
29//!
30//! ```
31//! use browser_automation_cli::error::{CliError, ErrorKind};
32//!
33//! let err = CliError::with_suggestion(
34//!     ErrorKind::Unavailable,
35//!     "chrome not found",
36//!     "install Chromium or Google Chrome",
37//! );
38//! assert_eq!(err.exit_code(), 69);
39//! assert_eq!(err.kind().as_str(), "unavailable");
40//! ```
41//!
42//! # See also
43//!
44//! - [`crate::envelope`] for JSON success/error envelopes
45//! - [`crate::exit_code_for`] for library callers that already hold a
46//!   [`CliError`](crate::error::CliError)
47
48use thiserror::Error;
49
50/// High-level failure category mapped to a process exit code.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ErrorKind {
53    /// Invalid usage or clap parse failure (`2`).
54    Usage,
55    /// Invalid data or payload (`65`).
56    Data,
57    /// Required input missing (`66`).
58    NoInput,
59    /// Browser or dependency unavailable (`69`).
60    Unavailable,
61    /// Internal software failure (`70`).
62    Software,
63    /// Browser runtime failure (`70`).
64    Browser,
65    /// CDP/protocol failure (`70`).
66    Protocol,
67    /// Wall-clock timeout (`124`).
68    Timeout,
69    /// Cancelled by signal (`130`).
70    Cancelled,
71    /// Broken pipe on stdout (`141`).
72    BrokenPipe,
73    /// Configuration error (`78`).
74    Config,
75    /// I/O failure (`74`).
76    Io,
77}
78
79impl ErrorKind {
80    /// Process exit code for this kind.
81    pub fn exit_code(self) -> u8 {
82        match self {
83            ErrorKind::Usage => 2,
84            ErrorKind::Data => 65,
85            ErrorKind::NoInput => 66,
86            ErrorKind::Unavailable => 69,
87            ErrorKind::Software | ErrorKind::Browser | ErrorKind::Protocol => 70,
88            ErrorKind::Config => 78,
89            ErrorKind::Io => 74,
90            ErrorKind::Timeout => 124,
91            ErrorKind::Cancelled => 130,
92            ErrorKind::BrokenPipe => 141,
93        }
94    }
95
96    /// Stable machine-readable kind string for JSON envelopes.
97    pub fn as_str(self) -> &'static str {
98        match self {
99            ErrorKind::Usage => "usage",
100            ErrorKind::Data => "data",
101            ErrorKind::NoInput => "no-input",
102            ErrorKind::Unavailable => "unavailable",
103            ErrorKind::Software => "software",
104            ErrorKind::Browser => "browser",
105            ErrorKind::Protocol => "protocol",
106            ErrorKind::Timeout => "timeout",
107            ErrorKind::Cancelled => "cancelled",
108            ErrorKind::BrokenPipe => "broken-pipe",
109            ErrorKind::Config => "config",
110            ErrorKind::Io => "io",
111        }
112    }
113}
114
115/// Typed CLI error with optional remediation hint.
116#[derive(Debug, Clone, Error)]
117#[error("{message}")]
118pub struct CliError {
119    kind: ErrorKind,
120    message: String,
121    suggestion: Option<String>,
122    /// Optional partial JSON payload (e.g. fail-fast `run` steps).
123    data: Option<serde_json::Value>,
124}
125
126impl CliError {
127    /// Create an error without a suggestion.
128    ///
129    /// Marked `#[cold]`: success paths dominate; keeps error construction out of
130    /// the predicted hot branch layout (rules_rust_eficiencia_e_performance).
131    #[cold]
132    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
133        Self {
134            kind,
135            message: message.into(),
136            suggestion: None,
137            data: None,
138        }
139    }
140
141    /// Create an error with a short remediation suggestion for agents.
142    #[cold]
143    pub fn with_suggestion(
144        kind: ErrorKind,
145        message: impl Into<String>,
146        suggestion: impl Into<String>,
147    ) -> Self {
148        Self {
149            kind,
150            message: message.into(),
151            suggestion: Some(suggestion.into()),
152            data: None,
153        }
154    }
155
156    /// Attach partial JSON `data` (kept on error envelopes).
157    pub fn with_data(mut self, data: serde_json::Value) -> Self {
158        self.data = Some(data);
159        self
160    }
161
162    /// Error category.
163    pub fn kind(&self) -> ErrorKind {
164        self.kind
165    }
166
167    /// Human-readable message (also used in JSON `error.message`).
168    pub fn message(&self) -> &str {
169        &self.message
170    }
171
172    /// Optional remediation hint.
173    pub fn suggestion(&self) -> Option<&str> {
174        self.suggestion.as_deref()
175    }
176
177    /// Optional partial data payload.
178    pub fn data(&self) -> Option<&serde_json::Value> {
179        self.data.as_ref()
180    }
181
182    /// Process exit code for this error.
183    pub fn exit_code(&self) -> u8 {
184        self.kind.exit_code()
185    }
186}
187