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 /// A filesystem failure outside the database (e.g. `database reset`
63 /// bootstrapping a missing state-home directory chain).
64 Io(std::io::Error),
65 /// Driver-domain config resolution failed (ADR-051 D8).
66 Config(ConfigError),
67 /// An unknown chain selector (`--chain foo`): the console names chain slugs
68 /// (`base`, `ethereum`) or numeric chain ids.
69 UnknownChain {
70 /// The rejected selector, verbatim.
71 chain: String,
72 },
73 /// The deployments registry has no record for the resolved
74 /// `(chain_id, name)` pair.
75 UnknownDeployment {
76 /// The resolved chain id.
77 chain_id: u64,
78 /// The DEX name slug (the `--name` value).
79 name: String,
80 },
81 /// A malformed block identifier — the exact `Invalid block tag: {tag}`
82 /// refusal the Python `_resolve_to_block` raises.
83 InvalidBlockTag(String),
84 /// The RPC read that resolves a `tag:offset` block identifier failed.
85 BlockResolution(String),
86 /// The supplied address does not parse (the click `Abort` arm of
87 /// `aave position show`).
88 InvalidAddress(String),
89 /// A required command argument is missing or malformed (`--pool-manager`
90 /// on `--family v4`, an out-of-range chain id).
91 InvalidArgument(String),
92 /// `aave update` found no active Aave markets (the Python
93 /// `DegenbotValueError`).
94 NoActiveAaveMarkets,
95 /// A `pool update` core failure (DB/RPC cancelled/verification).
96 PoolUpdate(PoolRunError),
97 /// An `aave` core failure (DB/RPC/verification/market-not-found).
98 AaveUpdate(AaveRunError),
99 /// A command arm that `block_on`s the process-wide shared runtime was invoked
100 /// from inside an existing `tokio` runtime. `run_pool_update`/`run_aave_update`
101 /// ride `get_runtime()` and must not nest; the arms hold the same
102 /// constraint.
103 RuntimeNested,
104 /// The operator host refused a command: the `{"ok": false, "error": ...}`
105 /// frame, rendered as one line (ADR-051 D6).
106 OperatorRefused(String),
107 /// A protocol-level failure talking to the operator host: an unreachable
108 /// socket, a timed-out exchange, or a malformed/non-object/missing-`ok`
109 /// response frame.
110 OperatorProtocol(String),
111 /// A client-side wire-hygiene refusal (an unknown `cordon_*` key, an
112 /// empty posture patch, an unknown hop-family string), raised BEFORE the
113 /// socket is touched. Domain validation stays server-side.
114 OperatorHygiene(String),
115}
116
117impl CliError {
118 /// The operator-facing line for this failure.
119 #[must_use]
120 pub fn message(&self) -> String {
121 match self {
122 Self::BootRefused(message)
123 | Self::BlockResolution(message)
124 | Self::InvalidArgument(message)
125 | Self::OperatorRefused(message)
126 | Self::OperatorProtocol(message)
127 | Self::OperatorHygiene(message) => message.clone(),
128 Self::Aborted => "Aborted!".to_string(),
129 Self::DatabaseUpgradeRetired => {
130 "the database upgrades itself at open; for an explicit repair, run \
131 `degenbot database heal`"
132 .to_string()
133 }
134 Self::DatabaseForeign => {
135 "The database is unrecognized (a foreign SQLite file); refused.".to_string()
136 }
137 Self::DatabaseNothingToDo => {
138 "The database has no legacy history; there is nothing to cut over.".to_string()
139 }
140 Self::Database(err) => err.to_string(),
141 Self::Io(err) => err.to_string(),
142 Self::Config(err) => err.to_string(),
143 Self::UnknownChain { chain } => format!(
144 "Unknown chain {chain:?}: expected a chain slug (base, ethereum) or a numeric \
145 chain id."
146 ),
147 Self::UnknownDeployment { chain_id, name } => {
148 format!("The deployments registry has no record for {name:?} on chain {chain_id}.")
149 }
150 Self::InvalidBlockTag(tag) => format!("Invalid block tag: {tag}"),
151 Self::InvalidAddress(address) => format!("Invalid address: {address}"),
152 Self::NoActiveAaveMarkets => "No active Aave markets found.".to_string(),
153 Self::PoolUpdate(err) => err.to_string(),
154 Self::AaveUpdate(err) => err.to_string(),
155 Self::RuntimeNested => "the command arms own their tokio runtime; do not run them \
156 from inside an existing runtime"
157 .to_string(),
158 }
159 }
160}
161
162impl fmt::Display for CliError {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 f.write_str(&self.message())
165 }
166}
167
168impl std::error::Error for CliError {
169 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
170 match self {
171 Self::Database(err) => Some(err),
172 Self::Config(err) => Some(err),
173 Self::PoolUpdate(err) => Some(err),
174 Self::AaveUpdate(err) => Some(err),
175 Self::Io(err) => Some(err),
176 _ => None,
177 }
178 }
179}
180
181/// Map a database error onto its typed console failure.
182///
183/// A foreign-file failure stays typed here — the facade must be able to point
184/// the operator at the right remedy without string matching.
185impl From<DbError> for CliError {
186 fn from(err: DbError) -> Self {
187 match err {
188 DbError::UnrecognizedSchema => Self::DatabaseForeign,
189 other => Self::Database(other),
190 }
191 }
192}
193
194/// Map a config-resolution error onto its typed console failure.
195impl From<ConfigError> for CliError {
196 fn from(err: ConfigError) -> Self {
197 Self::Config(err)
198 }
199}
200
201/// THE one `CliError → ExitCode` mapping site (ADR-051 D1).
202impl From<&CliError> for ExitCode {
203 fn from(err: &CliError) -> Self {
204 match err {
205 // FF-T1: the typed fleet boot refusal is the lone `EX_CONFIG` arm.
206 CliError::BootRefused(_) => Self::Config,
207 CliError::Aborted
208 | CliError::DatabaseUpgradeRetired
209 | CliError::DatabaseForeign
210 | CliError::DatabaseNothingToDo
211 | CliError::Database(_)
212 | CliError::Io(_)
213 | CliError::Config(_)
214 | CliError::UnknownChain { .. }
215 | CliError::UnknownDeployment { .. }
216 | CliError::InvalidBlockTag(_)
217 | CliError::BlockResolution(_)
218 | CliError::InvalidAddress(_)
219 | CliError::InvalidArgument(_)
220 | CliError::NoActiveAaveMarkets
221 | CliError::PoolUpdate(_)
222 | CliError::AaveUpdate(_)
223 | CliError::RuntimeNested
224 | CliError::OperatorRefused(_)
225 | CliError::OperatorProtocol(_)
226 | CliError::OperatorHygiene(_) => Self::Failure,
227 }
228 }
229}
230
231impl From<CliError> for ExitCode {
232 fn from(err: CliError) -> Self {
233 Self::from(&err)
234 }
235}