use std::path::PathBuf;
use alloy::primitives::U256;
use degenbot_db::ops::HealReport;
use degenbot_db::SchemaState;
use crate::error::{CliError, ExitCode};
use crate::pool::PoolFamily;
use crate::strategy::StrategyFacetDescriptor;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandReport {
Database(DatabaseReport),
Exchange(ExchangeReport),
Pool(PoolReport),
Aave(AaveReport),
Fleet(FleetReport),
Path(PathReport),
Strategy(StrategyReport),
Config(ConfigReport),
}
impl CommandReport {
#[must_use]
pub fn render_lines(&self) -> Vec<String> {
match self {
Self::Database(report) => report.render_lines(),
Self::Exchange(report) => report.render_lines(),
Self::Pool(report) => report.render_lines(),
Self::Aave(report) => report.render_lines(),
Self::Fleet(report) => report.render_lines(),
Self::Path(report) => report.render_lines(),
Self::Strategy(report) => report.render_lines(),
Self::Config(report) => report.render_lines(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigValue {
pub key: String,
pub value: String,
pub source: Option<degenbot_config::Source>,
}
impl ConfigValue {
#[must_use]
pub fn new(key: &str, value: String, source: degenbot_config::Source) -> Self {
Self {
key: key.to_string(),
value,
source: Some(source),
}
}
#[must_use]
pub fn unresolved(key: &str) -> Self {
Self {
key: key.to_string(),
value: String::new(),
source: None,
}
}
#[must_use]
pub fn line(&self) -> String {
match self.source {
Some(_) => format!(
"{} = {}",
self.key,
degenbot_config::redact_uri(&self.value)
),
None => format!("{} = (unresolved)", self.key),
}
}
#[must_use]
pub fn resolved_line(&self) -> String {
match self.source {
Some(source) => format!(
"{} = {} ({source})",
self.key,
degenbot_config::redact_uri(&self.value)
),
None => format!("{} = (unresolved)", self.key),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigReport {
Shown {
file: Option<PathBuf>,
values: Vec<ConfigValue>,
resolved: bool,
},
Path(PathBuf),
Got {
key: String,
value: String,
source: Option<degenbot_config::Source>,
},
Set {
key: String,
value: String,
outcome: crate::strategy::MutationOutcome,
},
Unset {
key: String,
outcome: crate::strategy::MutationOutcome,
},
}
impl ConfigReport {
#[must_use]
pub fn render_lines(&self) -> Vec<String> {
match self {
Self::Shown {
file,
values,
resolved,
} => {
let mut lines = vec![match file {
Some(path) if path.exists() => format!("config = {}", path.display()),
Some(path) => format!("config = {} (not yet created)", path.display()),
None => "config = (no config file location)".to_string(),
}];
lines.extend(values.iter().map(|value| {
if *resolved {
value.resolved_line()
} else {
value.line()
}
}));
lines
}
Self::Path(path) => vec![path.display().to_string()],
Self::Got { key, value, source } => vec![match source {
Some(source) => {
format!("{key} = {} ({source})", degenbot_config::redact_uri(value))
}
None => format!("{key} = (unresolved)"),
}],
Self::Set {
key,
value,
outcome,
} => vec![
format!("set {key} = {}", degenbot_config::redact_uri(value)),
shadow_line(outcome),
],
Self::Unset { key, outcome } => {
vec![format!("removed {key}"), shadow_line(outcome)]
}
}
}
}
#[derive(Debug)]
pub struct CommandOutcome {
result: Result<CommandReport, CliError>,
pub exit_code: ExitCode,
}
impl CommandOutcome {
#[must_use]
pub(crate) const fn new(result: Result<CommandReport, CliError>, exit_code: ExitCode) -> Self {
Self { result, exit_code }
}
#[must_use]
pub fn report(&self) -> Option<&CommandReport> {
self.result.as_ref().ok()
}
#[must_use]
pub fn error(&self) -> Option<&CliError> {
self.result.as_ref().err()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DryRunKind {
Cutover,
Heal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CutoverOutcome {
Converted,
AlreadyRustOwned,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DatabaseReport {
BackedUp {
source: PathBuf,
backup: PathBuf,
},
Reset {
path: PathBuf,
},
Compacted {
path: PathBuf,
},
Inspected {
path: PathBuf,
state: SchemaState,
},
Cutover {
path: PathBuf,
state: SchemaState,
outcome: CutoverOutcome,
},
Healed {
path: PathBuf,
report: HealReport,
},
DryRun {
path: PathBuf,
state: SchemaState,
kind: DryRunKind,
},
}
impl DatabaseReport {
#[must_use]
pub fn render_lines(&self) -> Vec<String> {
match self {
Self::BackedUp { backup, .. } => {
vec![format!("Backed up SQLite database to {}", backup.display())]
}
Self::Reset { path } => {
vec![format!(
"Initialized new SQLite database at {}",
path.display()
)]
}
Self::Compacted { path } => {
vec![format!("Compacted SQLite database at {}", path.display())]
}
Self::Inspected { state, .. } => {
vec![format!("Schema state: {}.", schema_state_label(state))]
}
Self::Cutover { path, outcome, .. } => match outcome {
CutoverOutcome::Converted => vec![format!(
"Cut over database at {} from Alembic to Rust ownership (the \
`alembic_version` table was dropped and `_degenbot_db_schema_version` \
was stamped).",
path.display()
)],
CutoverOutcome::AlreadyRustOwned => {
vec!["The database is already Rust-owned; cutover was a no-op.".to_string()]
}
},
Self::Healed { path, report } => healed_lines(path, report),
Self::DryRun { state, kind, .. } => vec![match kind {
DryRunKind::Cutover => cutover_dry_run_line(state),
DryRunKind::Heal => heal_dry_run_line(state),
}],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivateOutcome {
Activated,
AlreadyActive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeactivateOutcome {
Deactivated,
AlreadyDeactivated,
NoEntry,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExchangeActiveState {
Active,
Inactive,
NoEntry,
}
impl ExchangeActiveState {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Inactive => "inactive",
Self::NoEntry => "not in database",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExchangeListRow {
pub chain_id: u64,
pub chain_label: &'static str,
pub display_name: &'static str,
pub dex_slug: &'static str,
pub factory: String,
pub state: ExchangeActiveState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExchangeReport {
Activated {
chain_id: u64,
chain_label: &'static str,
display_name: &'static str,
dex_slug: &'static str,
outcome: ActivateOutcome,
},
Deactivated {
chain_id: u64,
chain_label: &'static str,
display_name: &'static str,
dex_slug: &'static str,
outcome: DeactivateOutcome,
},
Listed {
rows: Vec<ExchangeListRow>,
},
}
impl ExchangeReport {
#[must_use]
pub fn render_lines(&self) -> Vec<String> {
match self {
Self::Activated {
chain_id,
chain_label,
display_name,
outcome,
..
} => match outcome {
ActivateOutcome::Activated => vec![format!(
"Activated {display_name} on {chain_label} (chain ID {chain_id})."
)],
ActivateOutcome::AlreadyActive => {
vec!["Exchange is already activated.".to_string()]
}
},
Self::Deactivated {
chain_id,
chain_label,
display_name,
outcome,
..
} => match outcome {
DeactivateOutcome::Deactivated => vec![format!(
"Deactivated {display_name} on {chain_label} (chain ID {chain_id})."
)],
DeactivateOutcome::AlreadyDeactivated => {
vec!["Exchange is already deactivated.".to_string()]
}
DeactivateOutcome::NoEntry => vec![format!(
"The database has no entry for {display_name} on {chain_label} \
(chain ID {chain_id})."
)],
},
Self::Listed { rows } => rows
.iter()
.map(|row| {
format!(
"{} on {} (chain ID {}): {}",
row.display_name,
row.chain_label,
row.chain_id,
row.state.as_str()
)
})
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PoolReport {
Updated {
chain_id: i64,
from_block: u64,
to_block: u64,
chunks_committed: usize,
total_pools_written: usize,
total_liquidity_applies: usize,
},
UpdateCancelled {
chain_id: i64,
},
Verified {
pool: String,
family: PoolFamily,
block_number: u64,
divergences: Vec<degenbot_pool_updater::LiquidityDivergence>,
},
}
impl PoolReport {
#[must_use]
pub fn render_lines(&self) -> Vec<String> {
match self {
Self::Updated {
chain_id,
from_block,
to_block,
chunks_committed,
total_pools_written,
total_liquidity_applies,
} => vec![format!(
"Chain {chain_id}: advanced {from_block}->{to_block} in {chunks_committed} \
chunks ({total_pools_written} pools written, {total_liquidity_applies} \
liquidity applies)."
)],
Self::UpdateCancelled { chain_id } => vec![format!(
"Chain {chain_id}: cancelled (committed chunks stay durable)."
)],
Self::Verified {
pool,
family,
block_number,
divergences,
} => verification_lines(pool, *family, *block_number, divergences),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FleetReport {
Posture {
effective: String,
},
}
impl FleetReport {
#[must_use]
pub fn render_lines(&self) -> Vec<String> {
match self {
Self::Posture { effective } => vec![effective.clone()],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathReport {
Added {
detail: String,
},
Discovered {
detail: String,
},
}
impl PathReport {
#[must_use]
pub fn render_lines(&self) -> Vec<String> {
match self {
Self::Added { detail } | Self::Discovered { detail } => vec![detail.clone()],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StrategyReport {
Listed {
rows: Vec<StrategyFacetDescriptor>,
},
Shown {
descriptor: StrategyFacetDescriptor,
activation: Option<crate::strategy::EndpointSummary>,
},
Activated {
facet: crate::strategy::StrategyFacet,
summary: crate::strategy::EndpointSummary,
outcome: crate::strategy::MutationOutcome,
},
Deactivated {
facet: crate::strategy::StrategyFacet,
outcome: crate::strategy::MutationOutcome,
},
Set {
facet: crate::strategy::StrategyFacet,
key: &'static str,
outcome: crate::strategy::MutationOutcome,
},
Defaulted {
facet: crate::strategy::StrategyFacet,
key: &'static str,
},
}
impl StrategyReport {
#[must_use]
pub fn render_lines(&self) -> Vec<String> {
match self {
Self::Listed { rows } => rows
.iter()
.map(|descriptor| {
format!(
"{} ({}) -> {} [{} declared key(s)]",
descriptor.facet.as_str(),
descriptor.trigger_kind,
descriptor.config_section,
descriptor.fields.len()
)
})
.collect(),
Self::Shown {
descriptor,
activation,
} => {
let mut lines = vec![format!(
"{}: {} strategy",
descriptor.config_section, descriptor.trigger_kind
)];
lines.push(format!(" selector: {}", descriptor.facet.as_str()));
if let Some(summary) = activation {
lines.push(format!(" posture: {}", posture_line(summary)));
}
if descriptor.fields.is_empty() {
lines.push(" declared keys: (none yet)".to_string());
} else {
for (field, env) in descriptor.fields.iter().zip(&descriptor.envs) {
lines.push(format!(" {field} ({env})"));
}
}
lines
}
Self::Activated {
facet,
summary,
outcome,
} => vec![
format!("activated strategy {}", facet.as_str()),
format!(" endpoints: {}", posture_line(summary)),
shadow_line(outcome),
],
Self::Deactivated { facet, outcome } => vec![
format!(
"deactivated strategy {} (recorded endpoints kept)",
facet.as_str()
),
shadow_line(outcome),
],
Self::Set {
facet,
key,
outcome,
} => vec![
format!("set {}.{}", facet.config_section(), key),
shadow_line(outcome),
],
Self::Defaulted { facet, key } => vec![format!(
"restored {}.{} to its declared default",
facet.config_section(),
key
)],
}
}
}
fn posture_line(summary: &crate::strategy::EndpointSummary) -> String {
match summary {
crate::strategy::EndpointSummary::Inactive => "inactive".to_string(),
crate::strategy::EndpointSummary::Unset => {
"UNSET (activation requires --endpoints or --endpoints-default)".to_string()
}
crate::strategy::EndpointSummary::Default(urls) => {
format!("default ({})", urls.join(", "))
}
crate::strategy::EndpointSummary::Explicit(urls) => urls.join(", "),
}
}
fn shadow_line(outcome: &crate::strategy::MutationOutcome) -> String {
match outcome {
crate::strategy::MutationOutcome::Applied => " applies at load time".to_string(),
crate::strategy::MutationOutcome::Shadowed { env } => {
format!(" WARNING: {env} is set in the environment and will shadow this write")
}
}
}
fn verification_lines(
pool: &str,
family: PoolFamily,
block_number: u64,
divergences: &[degenbot_pool_updater::LiquidityDivergence],
) -> Vec<String> {
use degenbot_pool_updater::LiquidityDivergence;
if divergences.is_empty() {
return vec![format!(
"GREEN: {} pool {pool} matches on-chain truth at block {block_number}.",
family.as_str()
)];
}
let mut lines = vec![format!(
"RED: {} divergence(s) for {} pool {pool} at block {block_number}:",
divergences.len(),
family.as_str()
)];
for divergence in divergences {
lines.push(match divergence {
LiquidityDivergence::TickGross {
tick,
expected,
actual,
} => format!(" tick {tick}: TickGross expected={expected} actual={actual}"),
LiquidityDivergence::TickNet {
tick,
expected,
actual,
} => format!(" tick {tick}: TickNet expected={expected} actual={actual}"),
LiquidityDivergence::BitmapWord {
word,
expected,
actual,
} => format!(" word {word}: BitmapWord expected={expected} actual={actual}"),
LiquidityDivergence::TickPresence {
tick,
stored,
observed,
} => format!(" tick {tick}: TickPresence stored={stored} observed={observed}"),
});
}
lines
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AaveUpdateOutcome {
Advanced {
from_block: u64,
to_block: u64,
chunks_committed: usize,
total_events_applied: usize,
},
Cancelled,
NeedsBootstrap,
DryRun {
last_update_block: i64,
to_block: Option<u64>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AaveUpdateEntry {
pub chain_id: i64,
pub market_id: i64,
pub market_name: String,
pub outcome: AaveUpdateOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AavePositionLine {
pub symbol: String,
pub balance: U256,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AaveReport {
Activated {
chain_id: u64,
chain_label: &'static str,
market_id: i64,
market_name: String,
created: bool,
},
Deactivated {
chain_id: u64,
market_id: Option<i64>,
outcome: DeactivateOutcome,
},
Updated {
entries: Vec<AaveUpdateEntry>,
},
Position {
user_address: String,
market: String,
chain_id: u64,
collateral: Vec<AavePositionLine>,
debt: Vec<AavePositionLine>,
},
PositionNoMarket {
market: String,
chain_id: u64,
},
PositionNoUser {
user_address: String,
market: String,
chain_id: u64,
},
}
impl AaveReport {
#[must_use]
pub fn render_lines(&self) -> Vec<String> {
match self {
Self::Activated {
chain_id,
chain_label,
market_id,
market_name,
created,
} => vec![
format!("Activated Aave V3 on {chain_label} (chain ID {chain_id})."),
format!(" Market: {market_name} (id={market_id}, created={created})."),
],
Self::Deactivated {
chain_id, outcome, ..
} => match outcome {
DeactivateOutcome::Deactivated => vec![format!(
"Deactivated Aave V3 on {} (chain ID {chain_id}).",
chain_label_for(*chain_id)
)],
DeactivateOutcome::AlreadyDeactivated => Vec::new(),
DeactivateOutcome::NoEntry => {
vec![format!(
"The database has no entry for Aave V3 on {} (chain ID {chain_id}).",
chain_label_for(*chain_id)
)]
}
},
Self::Updated { entries } => entries.iter().flat_map(entry_lines).collect(),
Self::Position {
user_address,
market,
chain_id,
collateral,
debt,
} => position_lines(user_address, market, *chain_id, collateral, debt),
Self::PositionNoMarket { market, chain_id } => {
vec![format!(
"No market found with name '{market}' on chain {chain_id}."
)]
}
Self::PositionNoUser {
user_address,
market,
chain_id,
} => vec![format!(
"No Aave user found for address {user_address} in market '{market}' on chain \
{chain_id}."
)],
}
}
}
fn chain_label_for(chain_id: u64) -> String {
match chain_id {
1 => "Ethereum".to_string(),
8453 => "Base".to_string(),
other => other.to_string(),
}
}
fn entry_lines(entry: &AaveUpdateEntry) -> Vec<String> {
let AaveUpdateEntry {
chain_id,
market_id,
market_name,
outcome,
} = entry;
match outcome {
AaveUpdateOutcome::Advanced {
from_block,
to_block,
chunks_committed,
total_events_applied,
} => vec![format!(
"Chain {chain_id} market {market_id} ({market_name}): advanced {from_block}-> \
{to_block} in {chunks_committed} chunks ({total_events_applied} events applied)."
)],
AaveUpdateOutcome::Cancelled => vec![format!(
"Chain {chain_id} market {market_id}: cancelled (committed chunks stay durable)."
)],
AaveUpdateOutcome::NeedsBootstrap => vec![format!(
"Chain {chain_id} market {market_id} ({market_name}): needs bootstrapping \
(last_update_block is None); skipping. Bootstrap the stamp before running."
)],
AaveUpdateOutcome::DryRun {
last_update_block,
to_block,
} => vec![format!(
"Dry run: would advance chain {chain_id} market {market_id} ({market_name}) from \
block {last_update_block} to {} (no changes committed).",
render_opt_block(*to_block)
)],
}
}
fn render_opt_block(block: Option<u64>) -> String {
block.map_or_else(|| "None".to_string(), |n| n.to_string())
}
fn position_lines(
user_address: &str,
market: &str,
chain_id: u64,
collateral: &[AavePositionLine],
debt: &[AavePositionLine],
) -> Vec<String> {
let mut lines = vec![
format!("Aave V3 Positions for {user_address}"),
format!("Market: {market} (Chain: {chain_id})"),
"=".repeat(60),
];
if collateral.is_empty() {
lines.push("No collateral positions found.".to_string());
} else {
lines.push("Collateral Positions:".to_string());
lines.push("-".repeat(60));
lines.extend(
collateral
.iter()
.map(|p| format!(" {}: {} (scaled)", p.symbol, p.balance)),
);
}
if debt.is_empty() {
lines.push("No debt positions found.".to_string());
} else {
lines.push("Debt Positions:".to_string());
lines.push("-".repeat(60));
lines.extend(
debt.iter()
.map(|p| format!(" {}: {} (scaled)", p.symbol, p.balance)),
);
}
lines
}
fn healed_lines(path: &std::path::Path, report: &HealReport) -> Vec<String> {
if matches!(report.old_state, SchemaState::RustOwned { .. }) {
return vec![format!(
"Database at {} is already Rust-owned; heal is a no-op (no copy, no .bak).",
path.display()
)];
}
let total_rows: u64 = report.rows_copied.values().sum();
let n_tables = report.rows_copied.len();
let mut lines = vec![format!(
"Healed database at {}: {total_rows} rows across {n_tables} tables copied; old DB \
preserved at {}; new state: {}.",
path.display(),
report.bak_path.display(),
schema_state_label(&report.new_state)
)];
if !report.warnings.is_empty() {
lines.push(format!("Warnings: {}", report.warnings.join("; ")));
}
lines
}
#[must_use]
pub fn cutover_dry_run_line(state: &SchemaState) -> String {
let label = schema_state_label(state);
match state {
SchemaState::LegacyAlembic => format!(
"Schema state: {label}. Would cutover from Alembic to Rust ownership (drop \
`alembic_version`, stamp `_degenbot_db_schema_version`)."
),
SchemaState::RustOwned { .. } => {
format!("Schema state: {label}. Already Rust-owned — cutover is a no-op.")
}
SchemaState::FreshStandalone { .. } => {
format!("Schema state: {label}. No legacy history — nothing to cutover.")
}
SchemaState::Unrecognized => {
format!("Schema state: {label}. Unrecognized database (foreign file).")
}
}
}
#[must_use]
pub fn heal_dry_run_line(state: &SchemaState) -> String {
let label = schema_state_label(state);
match state {
SchemaState::LegacyAlembic => format!(
"Schema state: {label}. Would heal: rebuild at the Rust head schema, copy all \
rows, drop alembic_version, stamp _degenbot_db_schema_version, atomic-swap with \
a *.bak backup."
),
SchemaState::RustOwned { .. } => {
format!("Schema state: {label}. Already Rust-owned — heal is a no-op.")
}
SchemaState::FreshStandalone { .. } => format!(
"Schema state: {label}. Empty file — heal produces a fresh RustOwned DB (0 rows \
copied)."
),
SchemaState::Unrecognized => {
format!(
"Schema state: {label}. Unrecognized database (foreign file) — heal would \
refuse."
)
}
}
}
#[must_use]
pub fn schema_state_label(state: &SchemaState) -> &'static str {
match state {
SchemaState::LegacyAlembic => "legacy_alembic",
SchemaState::FreshStandalone { .. } => "fresh_standalone",
SchemaState::RustOwned { .. } => "rust_owned",
SchemaState::Unrecognized => "unrecognized",
}
}