degenbot_cli_core/error.rs
1//! Typed console failures and the single [`CliError`] → [`ExitCode`] mapping
2//! (ADR-051 D1).
3//!
4//! The workspace lint `exit = "deny"` forbids a library from aborting the host
5//! process: `run` returns codes. `EX_CONFIG` (78, sysexits) is the typed fleet
6//! boot refusal lifted out of `DegenbotCLI.invoke` (FF-T1, BPHR6F).
7
8use std::fmt;
9
10use degenbot_aave::RunError as AaveRunError;
11use degenbot_config::ConfigError;
12use degenbot_db::DbError;
13use degenbot_pool_updater::RunError as PoolRunError;
14
15/// The process exit code a command run maps to.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ExitCode {
18 /// `0`: the command completed (including a `--dry-run`, which writes nothing,
19 /// and a cooperative `Cancelled` run, whose committed chunks stay durable).
20 Success,
21 /// `1`: a typed command failure, including a declined confirmation (the click
22 /// `Abort` arm).
23 Failure,
24 /// `78` (sysexits `EX_CONFIG`): the typed fleet boot refusal — the host cannot
25 /// host the fleet configuration (FF-T1).
26 Config,
27}
28
29impl ExitCode {
30 /// The numeric process exit code.
31 #[must_use]
32 pub const fn code(self) -> i32 {
33 match self {
34 Self::Success => 0,
35 Self::Failure => 1,
36 Self::Config => 78,
37 }
38 }
39}
40
41/// A typed console failure.
42///
43/// Every variant carries the data the argv facade needs to render the
44/// operator-facing line through [`CliError::message`]; rendering itself is the
45/// facade's job (ADR-051 Q1).
46#[derive(Debug)]
47pub enum CliError {
48 /// The typed fleet boot refusal (FF-T1). Exits `EX_CONFIG` 78.
49 BootRefused(String),
50 /// The operator declined a confirmation prompt — the click `Abort` arm.
51 Aborted,
52 /// The `database upgrade` subcommand is retired: the database upgrades itself
53 /// at open (ADR-052), and `database heal` is the explicit repair.
54 DatabaseUpgradeRetired,
55 /// The file is a foreign `SQLite` database — the arm refuses to adopt it.
56 DatabaseForeign,
57 /// The schema state offers nothing for this arm (e.g. `cutover` on an empty
58 /// file with no legacy history).
59 DatabaseNothingToDo,
60 /// Any other database failure (I/O, integrity, heal verification).
61 Database(DbError),
62 /// Driver-domain config resolution failed (ADR-051 D8).
63 Config(ConfigError),
64 /// An unknown chain selector (`--chain foo`): the console names chain slugs
65 /// (`base`, `ethereum`) or numeric chain ids.
66 UnknownChain {
67 /// The rejected selector, verbatim.
68 chain: String,
69 },
70 /// The deployments registry has no record for the resolved
71 /// `(chain_id, name)` pair.
72 UnknownDeployment {
73 /// The resolved chain id.
74 chain_id: u64,
75 /// The DEX name slug (the `--name` value).
76 name: String,
77 },
78 /// A malformed block identifier — the exact `Invalid block tag: {tag}`
79 /// refusal the Python `_resolve_to_block` raises.
80 InvalidBlockTag(String),
81 /// The RPC read that resolves a `tag:offset` block identifier failed.
82 BlockResolution(String),
83 /// The supplied address does not parse (the click `Abort` arm of
84 /// `aave position show`).
85 InvalidAddress(String),
86 /// A required command argument is missing or malformed (`--pool-manager`
87 /// on `--family v4`, an out-of-range chain id).
88 InvalidArgument(String),
89 /// `aave update` found no active Aave markets (the Python
90 /// `DegenbotValueError`).
91 NoActiveAaveMarkets,
92 /// A `pool update` core failure (DB/RPC cancelled/verification).
93 PoolUpdate(PoolRunError),
94 /// An `aave` core failure (DB/RPC/verification/market-not-found).
95 AaveUpdate(AaveRunError),
96 /// A command arm that needs a self-built runtime was invoked from inside an
97 /// existing `tokio` runtime. `run_pool_update`/`run_aave_update` own their
98 /// runtime and must not nest; the arms hold the same constraint.
99 RuntimeNested,
100 /// The operator host refused a command: the `{"ok": false, "error": ...}`
101 /// frame, rendered as one line (ADR-051 D6).
102 OperatorRefused(String),
103 /// A protocol-level failure talking to the operator host: an unreachable
104 /// socket, a timed-out exchange, or a malformed/non-object/missing-`ok`
105 /// response frame.
106 OperatorProtocol(String),
107 /// A client-side wire-hygiene refusal (an unknown `cordon_*` key, an
108 /// empty posture patch, an unknown hop-family string), raised BEFORE the
109 /// socket is touched. Domain validation stays server-side.
110 OperatorHygiene(String),
111}
112
113impl CliError {
114 /// The operator-facing line for this failure.
115 #[must_use]
116 pub fn message(&self) -> String {
117 match self {
118 Self::BootRefused(message)
119 | Self::BlockResolution(message)
120 | Self::InvalidArgument(message)
121 | Self::OperatorRefused(message)
122 | Self::OperatorProtocol(message)
123 | Self::OperatorHygiene(message) => message.clone(),
124 Self::Aborted => "Aborted!".to_string(),
125 Self::DatabaseUpgradeRetired => {
126 "the database upgrades itself at open; for an explicit repair, run \
127 `degenbot database heal`"
128 .to_string()
129 }
130 Self::DatabaseForeign => {
131 "The database is unrecognized (a foreign SQLite file); refused.".to_string()
132 }
133 Self::DatabaseNothingToDo => {
134 "The database has no legacy history; there is nothing to cut over.".to_string()
135 }
136 Self::Database(err) => err.to_string(),
137 Self::Config(err) => err.to_string(),
138 Self::UnknownChain { chain } => format!(
139 "Unknown chain {chain:?}: expected a chain slug (base, ethereum) or a numeric \
140 chain id."
141 ),
142 Self::UnknownDeployment { chain_id, name } => {
143 format!("The deployments registry has no record for {name:?} on chain {chain_id}.")
144 }
145 Self::InvalidBlockTag(tag) => format!("Invalid block tag: {tag}"),
146 Self::InvalidAddress(address) => format!("Invalid address: {address}"),
147 Self::NoActiveAaveMarkets => "No active Aave markets found.".to_string(),
148 Self::PoolUpdate(err) => err.to_string(),
149 Self::AaveUpdate(err) => err.to_string(),
150 Self::RuntimeNested => "the command arms own their tokio runtime; do not run them \
151 from inside an existing runtime"
152 .to_string(),
153 }
154 }
155}
156
157impl fmt::Display for CliError {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 f.write_str(&self.message())
160 }
161}
162
163impl std::error::Error for CliError {
164 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
165 match self {
166 Self::Database(err) => Some(err),
167 Self::Config(err) => Some(err),
168 Self::PoolUpdate(err) => Some(err),
169 Self::AaveUpdate(err) => Some(err),
170 _ => None,
171 }
172 }
173}
174
175/// Map a database error onto its typed console failure.
176///
177/// A foreign-file failure stays typed here — the facade must be able to point
178/// the operator at the right remedy without string matching.
179impl From<DbError> for CliError {
180 fn from(err: DbError) -> Self {
181 match err {
182 DbError::UnrecognizedSchema => Self::DatabaseForeign,
183 other => Self::Database(other),
184 }
185 }
186}
187
188/// Map a config-resolution error onto its typed console failure.
189impl From<ConfigError> for CliError {
190 fn from(err: ConfigError) -> Self {
191 Self::Config(err)
192 }
193}
194
195/// THE one `CliError → ExitCode` mapping site (ADR-051 D1).
196impl From<&CliError> for ExitCode {
197 fn from(err: &CliError) -> Self {
198 match err {
199 // FF-T1: the typed fleet boot refusal is the lone `EX_CONFIG` arm.
200 CliError::BootRefused(_) => Self::Config,
201 CliError::Aborted
202 | CliError::DatabaseUpgradeRetired
203 | CliError::DatabaseForeign
204 | CliError::DatabaseNothingToDo
205 | CliError::Database(_)
206 | CliError::Config(_)
207 | CliError::UnknownChain { .. }
208 | CliError::UnknownDeployment { .. }
209 | CliError::InvalidBlockTag(_)
210 | CliError::BlockResolution(_)
211 | CliError::InvalidAddress(_)
212 | CliError::InvalidArgument(_)
213 | CliError::NoActiveAaveMarkets
214 | CliError::PoolUpdate(_)
215 | CliError::AaveUpdate(_)
216 | CliError::RuntimeNested
217 | CliError::OperatorRefused(_)
218 | CliError::OperatorProtocol(_)
219 | CliError::OperatorHygiene(_) => Self::Failure,
220 }
221 }
222}
223
224impl From<CliError> for ExitCode {
225 fn from(err: CliError) -> Self {
226 Self::from(&err)
227 }
228}