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    /// 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}