Skip to main content

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}