1use std::path::PathBuf;
5
6use alloy::primitives::U256;
7use degenbot_db::ops::HealReport;
8use degenbot_db::SchemaState;
9
10use crate::error::{CliError, ExitCode};
11use crate::pool::PoolFamily;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum CommandReport {
16 Database(DatabaseReport),
18 Exchange(ExchangeReport),
20 Pool(PoolReport),
22 Aave(AaveReport),
24 Fleet(FleetReport),
26 Path(PathReport),
28}
29
30impl CommandReport {
31 #[must_use]
33 pub fn render_lines(&self) -> Vec<String> {
34 match self {
35 Self::Database(report) => report.render_lines(),
36 Self::Exchange(report) => report.render_lines(),
37 Self::Pool(report) => report.render_lines(),
38 Self::Aave(report) => report.render_lines(),
39 Self::Fleet(report) => report.render_lines(),
40 Self::Path(report) => report.render_lines(),
41 }
42 }
43}
44
45#[derive(Debug)]
47pub struct CommandOutcome {
48 result: Result<CommandReport, CliError>,
49 pub exit_code: ExitCode,
51}
52
53impl CommandOutcome {
54 #[must_use]
56 pub(crate) const fn new(result: Result<CommandReport, CliError>, exit_code: ExitCode) -> Self {
57 Self { result, exit_code }
58 }
59
60 #[must_use]
62 pub fn report(&self) -> Option<&CommandReport> {
63 self.result.as_ref().ok()
64 }
65
66 #[must_use]
68 pub fn error(&self) -> Option<&CliError> {
69 self.result.as_ref().err()
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum DryRunKind {
76 Cutover,
78 Heal,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum CutoverOutcome {
85 Converted,
87 AlreadyRustOwned,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum DatabaseReport {
94 BackedUp {
96 source: PathBuf,
98 backup: PathBuf,
100 },
101 Reset {
103 path: PathBuf,
105 },
106 Compacted {
108 path: PathBuf,
110 },
111 Inspected {
113 path: PathBuf,
115 state: SchemaState,
117 },
118 Cutover {
120 path: PathBuf,
122 state: SchemaState,
124 outcome: CutoverOutcome,
126 },
127 Healed {
129 path: PathBuf,
131 report: HealReport,
133 },
134 DryRun {
136 path: PathBuf,
138 state: SchemaState,
140 kind: DryRunKind,
142 },
143}
144
145impl DatabaseReport {
146 #[must_use]
148 pub fn render_lines(&self) -> Vec<String> {
149 match self {
150 Self::BackedUp { backup, .. } => {
151 vec![format!("Backed up SQLite database to {}", backup.display())]
152 }
153 Self::Reset { path } => {
154 vec![format!(
155 "Initialized new SQLite database at {}",
156 path.display()
157 )]
158 }
159 Self::Compacted { path } => {
160 vec![format!("Compacted SQLite database at {}", path.display())]
161 }
162 Self::Inspected { state, .. } => {
163 vec![format!("Schema state: {}.", schema_state_label(state))]
164 }
165 Self::Cutover { path, outcome, .. } => match outcome {
166 CutoverOutcome::Converted => vec![format!(
167 "Cut over database at {} from Alembic to Rust ownership (the \
168 `alembic_version` table was dropped and `_degenbot_db_schema_version` \
169 was stamped).",
170 path.display()
171 )],
172 CutoverOutcome::AlreadyRustOwned => {
173 vec!["The database is already Rust-owned; cutover was a no-op.".to_string()]
174 }
175 },
176 Self::Healed { path, report } => healed_lines(path, report),
177 Self::DryRun { state, kind, .. } => vec![match kind {
178 DryRunKind::Cutover => cutover_dry_run_line(state),
179 DryRunKind::Heal => heal_dry_run_line(state),
180 }],
181 }
182 }
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum ActivateOutcome {
188 Activated,
190 AlreadyActive,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum DeactivateOutcome {
197 Deactivated,
199 AlreadyDeactivated,
201 NoEntry,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
207pub enum ExchangeReport {
208 Activated {
210 chain_id: u64,
212 chain_label: &'static str,
214 display_name: &'static str,
216 dex_slug: &'static str,
218 outcome: ActivateOutcome,
220 },
221 Deactivated {
223 chain_id: u64,
225 chain_label: &'static str,
227 display_name: &'static str,
229 dex_slug: &'static str,
231 outcome: DeactivateOutcome,
233 },
234}
235
236impl ExchangeReport {
237 #[must_use]
239 pub fn render_lines(&self) -> Vec<String> {
240 match self {
241 Self::Activated {
242 chain_id,
243 chain_label,
244 display_name,
245 outcome,
246 ..
247 } => match outcome {
248 ActivateOutcome::Activated => vec![format!(
249 "Activated {display_name} on {chain_label} (chain ID {chain_id})."
250 )],
251 ActivateOutcome::AlreadyActive => {
252 vec!["Exchange is already activated.".to_string()]
253 }
254 },
255 Self::Deactivated {
256 chain_id,
257 chain_label,
258 display_name,
259 outcome,
260 ..
261 } => match outcome {
262 DeactivateOutcome::Deactivated => vec![format!(
263 "Deactivated {display_name} on {chain_label} (chain ID {chain_id})."
264 )],
265 DeactivateOutcome::AlreadyDeactivated => {
266 vec!["Exchange is already deactivated.".to_string()]
267 }
268 DeactivateOutcome::NoEntry => vec![format!(
269 "The database has no entry for {display_name} on {chain_label} \
270 (chain ID {chain_id})."
271 )],
272 },
273 }
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Eq)]
279pub enum PoolReport {
280 Updated {
282 chain_id: i64,
284 from_block: u64,
286 to_block: u64,
288 chunks_committed: usize,
290 total_pools_written: usize,
292 total_liquidity_applies: usize,
294 },
295 UpdateCancelled {
297 chain_id: i64,
299 },
300 Verified {
302 pool: String,
304 family: PoolFamily,
306 block_number: u64,
308 divergences: Vec<degenbot_pool_updater::LiquidityDivergence>,
310 },
311}
312
313impl PoolReport {
314 #[must_use]
316 pub fn render_lines(&self) -> Vec<String> {
317 match self {
318 Self::Updated {
319 chain_id,
320 from_block,
321 to_block,
322 chunks_committed,
323 total_pools_written,
324 total_liquidity_applies,
325 } => vec![format!(
326 "Chain {chain_id}: advanced {from_block}->{to_block} in {chunks_committed} \
327 chunks ({total_pools_written} pools written, {total_liquidity_applies} \
328 liquidity applies)."
329 )],
330 Self::UpdateCancelled { chain_id } => vec![format!(
331 "Chain {chain_id}: cancelled (committed chunks stay durable)."
332 )],
333 Self::Verified {
334 pool,
335 family,
336 block_number,
337 divergences,
338 } => verification_lines(pool, *family, *block_number, divergences),
339 }
340 }
341}
342
343#[derive(Debug, Clone, PartialEq, Eq)]
345pub enum FleetReport {
346 Posture {
350 effective: String,
352 },
353}
354
355impl FleetReport {
356 #[must_use]
358 pub fn render_lines(&self) -> Vec<String> {
359 match self {
360 Self::Posture { effective } => vec![effective.clone()],
361 }
362 }
363}
364
365#[derive(Debug, Clone, PartialEq, Eq)]
367pub enum PathReport {
368 Added {
370 detail: String,
372 },
373 Discovered {
375 detail: String,
377 },
378}
379
380impl PathReport {
381 #[must_use]
383 pub fn render_lines(&self) -> Vec<String> {
384 match self {
385 Self::Added { detail } | Self::Discovered { detail } => vec![detail.clone()],
386 }
387 }
388}
389
390fn verification_lines(
392 pool: &str,
393 family: PoolFamily,
394 block_number: u64,
395 divergences: &[degenbot_pool_updater::LiquidityDivergence],
396) -> Vec<String> {
397 use degenbot_pool_updater::LiquidityDivergence;
398 if divergences.is_empty() {
399 return vec![format!(
400 "GREEN: {} pool {pool} matches on-chain truth at block {block_number}.",
401 family.as_str()
402 )];
403 }
404 let mut lines = vec![format!(
405 "RED: {} divergence(s) for {} pool {pool} at block {block_number}:",
406 divergences.len(),
407 family.as_str()
408 )];
409 for divergence in divergences {
410 lines.push(match divergence {
411 LiquidityDivergence::TickGross {
412 tick,
413 expected,
414 actual,
415 } => format!(" tick {tick}: TickGross expected={expected} actual={actual}"),
416 LiquidityDivergence::TickNet {
417 tick,
418 expected,
419 actual,
420 } => format!(" tick {tick}: TickNet expected={expected} actual={actual}"),
421 LiquidityDivergence::BitmapWord {
422 word,
423 expected,
424 actual,
425 } => format!(" word {word}: BitmapWord expected={expected} actual={actual}"),
426 LiquidityDivergence::TickCallReverted { tick } => {
427 format!(" tick {tick}: TickCallReverted")
428 }
429 LiquidityDivergence::BitmapCallReverted { word } => {
430 format!(" word {word}: BitmapCallReverted")
431 }
432 });
433 }
434 lines
435}
436
437#[derive(Debug, Clone, PartialEq, Eq)]
439pub enum AaveUpdateOutcome {
440 Advanced {
442 from_block: u64,
444 to_block: u64,
446 chunks_committed: usize,
448 total_events_applied: usize,
450 },
451 Cancelled,
453 NeedsBootstrap,
455 DryRun {
457 last_update_block: i64,
459 to_block: Option<u64>,
461 },
462}
463
464#[derive(Debug, Clone, PartialEq, Eq)]
466pub struct AaveUpdateEntry {
467 pub chain_id: i64,
469 pub market_id: i64,
471 pub market_name: String,
473 pub outcome: AaveUpdateOutcome,
475}
476
477#[derive(Debug, Clone, PartialEq, Eq)]
479pub struct AavePositionLine {
480 pub symbol: String,
482 pub balance: U256,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq)]
488pub enum AaveReport {
489 Activated {
491 chain_id: u64,
493 chain_label: &'static str,
495 market_id: i64,
497 market_name: String,
499 created: bool,
501 },
502 Deactivated {
504 chain_id: u64,
506 market_id: Option<i64>,
508 outcome: DeactivateOutcome,
510 },
511 Updated {
513 entries: Vec<AaveUpdateEntry>,
515 },
516 Position {
518 user_address: String,
520 market: String,
522 chain_id: u64,
524 collateral: Vec<AavePositionLine>,
526 debt: Vec<AavePositionLine>,
528 },
529 PositionNoMarket {
531 market: String,
533 chain_id: u64,
535 },
536 PositionNoUser {
538 user_address: String,
540 market: String,
542 chain_id: u64,
544 },
545}
546
547impl AaveReport {
548 #[must_use]
550 pub fn render_lines(&self) -> Vec<String> {
551 match self {
552 Self::Activated {
553 chain_id,
554 chain_label,
555 market_id,
556 market_name,
557 created,
558 } => vec![
559 format!("Activated Aave V3 on {chain_label} (chain ID {chain_id})."),
560 format!(" Market: {market_name} (id={market_id}, created={created})."),
561 ],
562 Self::Deactivated {
563 chain_id, outcome, ..
564 } => match outcome {
565 DeactivateOutcome::Deactivated => vec![format!(
566 "Deactivated Aave V3 on {} (chain ID {chain_id}).",
567 chain_label_for(*chain_id)
568 )],
569 DeactivateOutcome::AlreadyDeactivated => Vec::new(),
570 DeactivateOutcome::NoEntry => {
571 vec![format!(
572 "The database has no entry for Aave V3 on {} (chain ID {chain_id}).",
573 chain_label_for(*chain_id)
574 )]
575 }
576 },
577 Self::Updated { entries } => entries.iter().flat_map(entry_lines).collect(),
578 Self::Position {
579 user_address,
580 market,
581 chain_id,
582 collateral,
583 debt,
584 } => position_lines(user_address, market, *chain_id, collateral, debt),
585 Self::PositionNoMarket { market, chain_id } => {
586 vec![format!(
587 "No market found with name '{market}' on chain {chain_id}."
588 )]
589 }
590 Self::PositionNoUser {
591 user_address,
592 market,
593 chain_id,
594 } => vec![format!(
595 "No Aave user found for address {user_address} in market '{market}' on chain \
596 {chain_id}."
597 )],
598 }
599 }
600}
601
602fn chain_label_for(chain_id: u64) -> String {
604 match chain_id {
605 1 => "Ethereum".to_string(),
606 8453 => "Base".to_string(),
607 other => other.to_string(),
608 }
609}
610
611fn entry_lines(entry: &AaveUpdateEntry) -> Vec<String> {
613 let AaveUpdateEntry {
614 chain_id,
615 market_id,
616 market_name,
617 outcome,
618 } = entry;
619 match outcome {
620 AaveUpdateOutcome::Advanced {
621 from_block,
622 to_block,
623 chunks_committed,
624 total_events_applied,
625 } => vec![format!(
626 "Chain {chain_id} market {market_id} ({market_name}): advanced {from_block}-> \
627 {to_block} in {chunks_committed} chunks ({total_events_applied} events applied)."
628 )],
629 AaveUpdateOutcome::Cancelled => vec![format!(
630 "Chain {chain_id} market {market_id}: cancelled (committed chunks stay durable)."
631 )],
632 AaveUpdateOutcome::NeedsBootstrap => vec![format!(
633 "Chain {chain_id} market {market_id} ({market_name}): needs bootstrapping \
634 (last_update_block is None); skipping. Bootstrap the stamp before running."
635 )],
636 AaveUpdateOutcome::DryRun {
637 last_update_block,
638 to_block,
639 } => vec![format!(
640 "Dry run: would advance chain {chain_id} market {market_id} ({market_name}) from \
641 block {last_update_block} to {} (no changes committed).",
642 render_opt_block(*to_block)
643 )],
644 }
645}
646
647fn render_opt_block(block: Option<u64>) -> String {
649 block.map_or_else(|| "None".to_string(), |n| n.to_string())
650}
651
652fn position_lines(
654 user_address: &str,
655 market: &str,
656 chain_id: u64,
657 collateral: &[AavePositionLine],
658 debt: &[AavePositionLine],
659) -> Vec<String> {
660 let mut lines = vec![
661 format!("Aave V3 Positions for {user_address}"),
662 format!("Market: {market} (Chain: {chain_id})"),
663 "=".repeat(60),
664 ];
665 if collateral.is_empty() {
666 lines.push("No collateral positions found.".to_string());
667 } else {
668 lines.push("Collateral Positions:".to_string());
669 lines.push("-".repeat(60));
670 lines.extend(
671 collateral
672 .iter()
673 .map(|p| format!(" {}: {} (scaled)", p.symbol, p.balance)),
674 );
675 }
676 if debt.is_empty() {
677 lines.push("No debt positions found.".to_string());
678 } else {
679 lines.push("Debt Positions:".to_string());
680 lines.push("-".repeat(60));
681 lines.extend(
682 debt.iter()
683 .map(|p| format!(" {}: {} (scaled)", p.symbol, p.balance)),
684 );
685 }
686 lines
687}
688
689fn healed_lines(path: &std::path::Path, report: &HealReport) -> Vec<String> {
691 if matches!(report.old_state, SchemaState::RustOwned { .. }) {
692 return vec![format!(
693 "Database at {} is already Rust-owned; heal is a no-op (no copy, no .bak).",
694 path.display()
695 )];
696 }
697 let total_rows: u64 = report.rows_copied.values().sum();
698 let n_tables = report.rows_copied.len();
699 let mut lines = vec![format!(
700 "Healed database at {}: {total_rows} rows across {n_tables} tables copied; old DB \
701 preserved at {}; new state: {}.",
702 path.display(),
703 report.bak_path.display(),
704 schema_state_label(&report.new_state)
705 )];
706 if !report.warnings.is_empty() {
707 lines.push(format!("Warnings: {}", report.warnings.join("; ")));
708 }
709 lines
710}
711
712#[must_use]
714pub fn cutover_dry_run_line(state: &SchemaState) -> String {
715 let label = schema_state_label(state);
716 match state {
717 SchemaState::LegacyAlembic => format!(
718 "Schema state: {label}. Would cutover from Alembic to Rust ownership (drop \
719 `alembic_version`, stamp `_degenbot_db_schema_version`)."
720 ),
721 SchemaState::RustOwned { .. } => {
722 format!("Schema state: {label}. Already Rust-owned — cutover is a no-op.")
723 }
724 SchemaState::FreshStandalone { .. } => {
725 format!("Schema state: {label}. No legacy history — nothing to cutover.")
726 }
727 SchemaState::Unrecognized => {
728 format!("Schema state: {label}. Unrecognized database (foreign file).")
729 }
730 }
731}
732
733#[must_use]
735pub fn heal_dry_run_line(state: &SchemaState) -> String {
736 let label = schema_state_label(state);
737 match state {
738 SchemaState::LegacyAlembic => format!(
739 "Schema state: {label}. Would heal: rebuild at the Rust head schema, copy all \
740 rows, drop alembic_version, stamp _degenbot_db_schema_version, atomic-swap with \
741 a *.bak backup."
742 ),
743 SchemaState::RustOwned { .. } => {
744 format!("Schema state: {label}. Already Rust-owned — heal is a no-op.")
745 }
746 SchemaState::FreshStandalone { .. } => format!(
747 "Schema state: {label}. Empty file — heal produces a fresh RustOwned DB (0 rows \
748 copied)."
749 ),
750 SchemaState::Unrecognized => {
751 format!(
752 "Schema state: {label}. Unrecognized database (foreign file) — heal would \
753 refuse."
754 )
755 }
756 }
757}
758
759#[must_use]
762pub fn schema_state_label(state: &SchemaState) -> &'static str {
763 match state {
764 SchemaState::LegacyAlembic => "legacy_alembic",
765 SchemaState::FreshStandalone { .. } => "fresh_standalone",
766 SchemaState::RustOwned { .. } => "rust_owned",
767 SchemaState::Unrecognized => "unrecognized",
768 }
769}