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;
12use crate::strategy::StrategyFacetDescriptor;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum CommandReport {
17 Database(DatabaseReport),
19 Exchange(ExchangeReport),
21 Pool(PoolReport),
23 Aave(AaveReport),
25 Fleet(FleetReport),
27 Path(PathReport),
29 Strategy(StrategyReport),
31}
32
33impl CommandReport {
34 #[must_use]
36 pub fn render_lines(&self) -> Vec<String> {
37 match self {
38 Self::Database(report) => report.render_lines(),
39 Self::Exchange(report) => report.render_lines(),
40 Self::Pool(report) => report.render_lines(),
41 Self::Aave(report) => report.render_lines(),
42 Self::Fleet(report) => report.render_lines(),
43 Self::Path(report) => report.render_lines(),
44 Self::Strategy(report) => report.render_lines(),
45 }
46 }
47}
48
49#[derive(Debug)]
51pub struct CommandOutcome {
52 result: Result<CommandReport, CliError>,
53 pub exit_code: ExitCode,
55}
56
57impl CommandOutcome {
58 #[must_use]
60 pub(crate) const fn new(result: Result<CommandReport, CliError>, exit_code: ExitCode) -> Self {
61 Self { result, exit_code }
62 }
63
64 #[must_use]
66 pub fn report(&self) -> Option<&CommandReport> {
67 self.result.as_ref().ok()
68 }
69
70 #[must_use]
72 pub fn error(&self) -> Option<&CliError> {
73 self.result.as_ref().err()
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum DryRunKind {
80 Cutover,
82 Heal,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum CutoverOutcome {
89 Converted,
91 AlreadyRustOwned,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum DatabaseReport {
98 BackedUp {
100 source: PathBuf,
102 backup: PathBuf,
104 },
105 Reset {
107 path: PathBuf,
109 },
110 Compacted {
112 path: PathBuf,
114 },
115 Inspected {
117 path: PathBuf,
119 state: SchemaState,
121 },
122 Cutover {
124 path: PathBuf,
126 state: SchemaState,
128 outcome: CutoverOutcome,
130 },
131 Healed {
133 path: PathBuf,
135 report: HealReport,
137 },
138 DryRun {
140 path: PathBuf,
142 state: SchemaState,
144 kind: DryRunKind,
146 },
147}
148
149impl DatabaseReport {
150 #[must_use]
152 pub fn render_lines(&self) -> Vec<String> {
153 match self {
154 Self::BackedUp { backup, .. } => {
155 vec![format!("Backed up SQLite database to {}", backup.display())]
156 }
157 Self::Reset { path } => {
158 vec![format!(
159 "Initialized new SQLite database at {}",
160 path.display()
161 )]
162 }
163 Self::Compacted { path } => {
164 vec![format!("Compacted SQLite database at {}", path.display())]
165 }
166 Self::Inspected { state, .. } => {
167 vec![format!("Schema state: {}.", schema_state_label(state))]
168 }
169 Self::Cutover { path, outcome, .. } => match outcome {
170 CutoverOutcome::Converted => vec![format!(
171 "Cut over database at {} from Alembic to Rust ownership (the \
172 `alembic_version` table was dropped and `_degenbot_db_schema_version` \
173 was stamped).",
174 path.display()
175 )],
176 CutoverOutcome::AlreadyRustOwned => {
177 vec!["The database is already Rust-owned; cutover was a no-op.".to_string()]
178 }
179 },
180 Self::Healed { path, report } => healed_lines(path, report),
181 Self::DryRun { state, kind, .. } => vec![match kind {
182 DryRunKind::Cutover => cutover_dry_run_line(state),
183 DryRunKind::Heal => heal_dry_run_line(state),
184 }],
185 }
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum ActivateOutcome {
192 Activated,
194 AlreadyActive,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum DeactivateOutcome {
201 Deactivated,
203 AlreadyDeactivated,
205 NoEntry,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum ExchangeActiveState {
212 Active,
214 Inactive,
216 NoEntry,
218}
219
220impl ExchangeActiveState {
221 #[must_use]
223 pub const fn as_str(self) -> &'static str {
224 match self {
225 Self::Active => "active",
226 Self::Inactive => "inactive",
227 Self::NoEntry => "not in database",
228 }
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct ExchangeListRow {
235 pub chain_id: u64,
237 pub chain_label: &'static str,
239 pub display_name: &'static str,
241 pub dex_slug: &'static str,
243 pub factory: String,
245 pub state: ExchangeActiveState,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq)]
251pub enum ExchangeReport {
252 Activated {
254 chain_id: u64,
256 chain_label: &'static str,
258 display_name: &'static str,
260 dex_slug: &'static str,
262 outcome: ActivateOutcome,
264 },
265 Deactivated {
267 chain_id: u64,
269 chain_label: &'static str,
271 display_name: &'static str,
273 dex_slug: &'static str,
275 outcome: DeactivateOutcome,
277 },
278 Listed {
280 rows: Vec<ExchangeListRow>,
282 },
283}
284
285impl ExchangeReport {
286 #[must_use]
288 pub fn render_lines(&self) -> Vec<String> {
289 match self {
290 Self::Activated {
291 chain_id,
292 chain_label,
293 display_name,
294 outcome,
295 ..
296 } => match outcome {
297 ActivateOutcome::Activated => vec![format!(
298 "Activated {display_name} on {chain_label} (chain ID {chain_id})."
299 )],
300 ActivateOutcome::AlreadyActive => {
301 vec!["Exchange is already activated.".to_string()]
302 }
303 },
304 Self::Deactivated {
305 chain_id,
306 chain_label,
307 display_name,
308 outcome,
309 ..
310 } => match outcome {
311 DeactivateOutcome::Deactivated => vec![format!(
312 "Deactivated {display_name} on {chain_label} (chain ID {chain_id})."
313 )],
314 DeactivateOutcome::AlreadyDeactivated => {
315 vec!["Exchange is already deactivated.".to_string()]
316 }
317 DeactivateOutcome::NoEntry => vec![format!(
318 "The database has no entry for {display_name} on {chain_label} \
319 (chain ID {chain_id})."
320 )],
321 },
322 Self::Listed { rows } => rows
323 .iter()
324 .map(|row| {
325 format!(
326 "{} on {} (chain ID {}): {}",
327 row.display_name,
328 row.chain_label,
329 row.chain_id,
330 row.state.as_str()
331 )
332 })
333 .collect(),
334 }
335 }
336}
337
338#[derive(Debug, Clone, PartialEq, Eq)]
340pub enum PoolReport {
341 Updated {
343 chain_id: i64,
345 from_block: u64,
347 to_block: u64,
349 chunks_committed: usize,
351 total_pools_written: usize,
353 total_liquidity_applies: usize,
355 },
356 UpdateCancelled {
358 chain_id: i64,
360 },
361 Verified {
363 pool: String,
365 family: PoolFamily,
367 block_number: u64,
369 divergences: Vec<degenbot_pool_updater::LiquidityDivergence>,
371 },
372}
373
374impl PoolReport {
375 #[must_use]
377 pub fn render_lines(&self) -> Vec<String> {
378 match self {
379 Self::Updated {
380 chain_id,
381 from_block,
382 to_block,
383 chunks_committed,
384 total_pools_written,
385 total_liquidity_applies,
386 } => vec![format!(
387 "Chain {chain_id}: advanced {from_block}->{to_block} in {chunks_committed} \
388 chunks ({total_pools_written} pools written, {total_liquidity_applies} \
389 liquidity applies)."
390 )],
391 Self::UpdateCancelled { chain_id } => vec![format!(
392 "Chain {chain_id}: cancelled (committed chunks stay durable)."
393 )],
394 Self::Verified {
395 pool,
396 family,
397 block_number,
398 divergences,
399 } => verification_lines(pool, *family, *block_number, divergences),
400 }
401 }
402}
403
404#[derive(Debug, Clone, PartialEq, Eq)]
406pub enum FleetReport {
407 Posture {
411 effective: String,
413 },
414}
415
416impl FleetReport {
417 #[must_use]
419 pub fn render_lines(&self) -> Vec<String> {
420 match self {
421 Self::Posture { effective } => vec![effective.clone()],
422 }
423 }
424}
425
426#[derive(Debug, Clone, PartialEq, Eq)]
428pub enum PathReport {
429 Added {
431 detail: String,
433 },
434 Discovered {
436 detail: String,
438 },
439}
440
441impl PathReport {
442 #[must_use]
444 pub fn render_lines(&self) -> Vec<String> {
445 match self {
446 Self::Added { detail } | Self::Discovered { detail } => vec![detail.clone()],
447 }
448 }
449}
450
451#[derive(Debug, Clone, PartialEq, Eq)]
453pub enum StrategyReport {
454 Listed {
456 rows: Vec<StrategyFacetDescriptor>,
458 },
459 Shown {
461 descriptor: StrategyFacetDescriptor,
463 activation: Option<crate::strategy::EndpointSummary>,
466 },
467 Activated {
469 facet: crate::strategy::StrategyFacet,
471 summary: crate::strategy::EndpointSummary,
473 outcome: crate::strategy::MutationOutcome,
475 },
476 Deactivated {
478 facet: crate::strategy::StrategyFacet,
480 outcome: crate::strategy::MutationOutcome,
482 },
483 Set {
485 facet: crate::strategy::StrategyFacet,
487 key: &'static str,
489 outcome: crate::strategy::MutationOutcome,
491 },
492 Defaulted {
495 facet: crate::strategy::StrategyFacet,
497 key: &'static str,
499 },
500}
501
502impl StrategyReport {
503 #[must_use]
505 pub fn render_lines(&self) -> Vec<String> {
506 match self {
507 Self::Listed { rows } => rows
508 .iter()
509 .map(|descriptor| {
510 format!(
511 "{} ({}) -> {} [{} declared key(s)]",
512 descriptor.facet.as_str(),
513 descriptor.trigger_kind,
514 descriptor.config_section,
515 descriptor.fields.len()
516 )
517 })
518 .collect(),
519 Self::Shown {
520 descriptor,
521 activation,
522 } => {
523 let mut lines = vec![format!(
524 "{}: {} strategy",
525 descriptor.config_section, descriptor.trigger_kind
526 )];
527 lines.push(format!(" selector: {}", descriptor.facet.as_str()));
528 if let Some(summary) = activation {
529 lines.push(format!(" posture: {}", posture_line(summary)));
530 }
531 if descriptor.fields.is_empty() {
532 lines.push(" declared keys: (none yet)".to_string());
533 } else {
534 for (field, env) in descriptor.fields.iter().zip(&descriptor.envs) {
535 lines.push(format!(" {field} ({env})"));
536 }
537 }
538 lines
539 }
540 Self::Activated {
541 facet,
542 summary,
543 outcome,
544 } => vec![
545 format!("activated strategy {}", facet.as_str()),
546 format!(" endpoints: {}", posture_line(summary)),
547 shadow_line(outcome),
548 ],
549 Self::Deactivated { facet, outcome } => vec![
550 format!(
551 "deactivated strategy {} (recorded endpoints kept)",
552 facet.as_str()
553 ),
554 shadow_line(outcome),
555 ],
556 Self::Set {
557 facet,
558 key,
559 outcome,
560 } => vec![
561 format!("set {}.{}", facet.config_section(), key),
562 shadow_line(outcome),
563 ],
564 Self::Defaulted { facet, key } => vec![format!(
565 "restored {}.{} to its declared default",
566 facet.config_section(),
567 key
568 )],
569 }
570 }
571}
572
573fn posture_line(summary: &crate::strategy::EndpointSummary) -> String {
575 match summary {
576 crate::strategy::EndpointSummary::Inactive => "inactive".to_string(),
577 crate::strategy::EndpointSummary::Unset => {
578 "UNSET (activation requires --endpoints or --endpoints-default)".to_string()
579 }
580 crate::strategy::EndpointSummary::Default(urls) => {
581 format!("default ({})", urls.join(", "))
582 }
583 crate::strategy::EndpointSummary::Explicit(urls) => urls.join(", "),
584 }
585}
586
587fn shadow_line(outcome: &crate::strategy::MutationOutcome) -> String {
589 match outcome {
590 crate::strategy::MutationOutcome::Applied => " applies at load time".to_string(),
591 crate::strategy::MutationOutcome::Shadowed { env } => {
592 format!(" WARNING: {env} is set in the environment and will shadow this write")
593 }
594 }
595}
596
597fn verification_lines(
599 pool: &str,
600 family: PoolFamily,
601 block_number: u64,
602 divergences: &[degenbot_pool_updater::LiquidityDivergence],
603) -> Vec<String> {
604 use degenbot_pool_updater::LiquidityDivergence;
605 if divergences.is_empty() {
606 return vec![format!(
607 "GREEN: {} pool {pool} matches on-chain truth at block {block_number}.",
608 family.as_str()
609 )];
610 }
611 let mut lines = vec![format!(
612 "RED: {} divergence(s) for {} pool {pool} at block {block_number}:",
613 divergences.len(),
614 family.as_str()
615 )];
616 for divergence in divergences {
617 lines.push(match divergence {
618 LiquidityDivergence::TickGross {
619 tick,
620 expected,
621 actual,
622 } => format!(" tick {tick}: TickGross expected={expected} actual={actual}"),
623 LiquidityDivergence::TickNet {
624 tick,
625 expected,
626 actual,
627 } => format!(" tick {tick}: TickNet expected={expected} actual={actual}"),
628 LiquidityDivergence::BitmapWord {
629 word,
630 expected,
631 actual,
632 } => format!(" word {word}: BitmapWord expected={expected} actual={actual}"),
633 LiquidityDivergence::TickCallReverted { tick } => {
634 format!(" tick {tick}: TickCallReverted")
635 }
636 LiquidityDivergence::BitmapCallReverted { word } => {
637 format!(" word {word}: BitmapCallReverted")
638 }
639 });
640 }
641 lines
642}
643
644#[derive(Debug, Clone, PartialEq, Eq)]
646pub enum AaveUpdateOutcome {
647 Advanced {
649 from_block: u64,
651 to_block: u64,
653 chunks_committed: usize,
655 total_events_applied: usize,
657 },
658 Cancelled,
660 NeedsBootstrap,
662 DryRun {
664 last_update_block: i64,
666 to_block: Option<u64>,
668 },
669}
670
671#[derive(Debug, Clone, PartialEq, Eq)]
673pub struct AaveUpdateEntry {
674 pub chain_id: i64,
676 pub market_id: i64,
678 pub market_name: String,
680 pub outcome: AaveUpdateOutcome,
682}
683
684#[derive(Debug, Clone, PartialEq, Eq)]
686pub struct AavePositionLine {
687 pub symbol: String,
689 pub balance: U256,
691}
692
693#[derive(Debug, Clone, PartialEq, Eq)]
695pub enum AaveReport {
696 Activated {
698 chain_id: u64,
700 chain_label: &'static str,
702 market_id: i64,
704 market_name: String,
706 created: bool,
708 },
709 Deactivated {
711 chain_id: u64,
713 market_id: Option<i64>,
715 outcome: DeactivateOutcome,
717 },
718 Updated {
720 entries: Vec<AaveUpdateEntry>,
722 },
723 Position {
725 user_address: String,
727 market: String,
729 chain_id: u64,
731 collateral: Vec<AavePositionLine>,
733 debt: Vec<AavePositionLine>,
735 },
736 PositionNoMarket {
738 market: String,
740 chain_id: u64,
742 },
743 PositionNoUser {
745 user_address: String,
747 market: String,
749 chain_id: u64,
751 },
752}
753
754impl AaveReport {
755 #[must_use]
757 pub fn render_lines(&self) -> Vec<String> {
758 match self {
759 Self::Activated {
760 chain_id,
761 chain_label,
762 market_id,
763 market_name,
764 created,
765 } => vec![
766 format!("Activated Aave V3 on {chain_label} (chain ID {chain_id})."),
767 format!(" Market: {market_name} (id={market_id}, created={created})."),
768 ],
769 Self::Deactivated {
770 chain_id, outcome, ..
771 } => match outcome {
772 DeactivateOutcome::Deactivated => vec![format!(
773 "Deactivated Aave V3 on {} (chain ID {chain_id}).",
774 chain_label_for(*chain_id)
775 )],
776 DeactivateOutcome::AlreadyDeactivated => Vec::new(),
777 DeactivateOutcome::NoEntry => {
778 vec![format!(
779 "The database has no entry for Aave V3 on {} (chain ID {chain_id}).",
780 chain_label_for(*chain_id)
781 )]
782 }
783 },
784 Self::Updated { entries } => entries.iter().flat_map(entry_lines).collect(),
785 Self::Position {
786 user_address,
787 market,
788 chain_id,
789 collateral,
790 debt,
791 } => position_lines(user_address, market, *chain_id, collateral, debt),
792 Self::PositionNoMarket { market, chain_id } => {
793 vec![format!(
794 "No market found with name '{market}' on chain {chain_id}."
795 )]
796 }
797 Self::PositionNoUser {
798 user_address,
799 market,
800 chain_id,
801 } => vec![format!(
802 "No Aave user found for address {user_address} in market '{market}' on chain \
803 {chain_id}."
804 )],
805 }
806 }
807}
808
809fn chain_label_for(chain_id: u64) -> String {
811 match chain_id {
812 1 => "Ethereum".to_string(),
813 8453 => "Base".to_string(),
814 other => other.to_string(),
815 }
816}
817
818fn entry_lines(entry: &AaveUpdateEntry) -> Vec<String> {
820 let AaveUpdateEntry {
821 chain_id,
822 market_id,
823 market_name,
824 outcome,
825 } = entry;
826 match outcome {
827 AaveUpdateOutcome::Advanced {
828 from_block,
829 to_block,
830 chunks_committed,
831 total_events_applied,
832 } => vec![format!(
833 "Chain {chain_id} market {market_id} ({market_name}): advanced {from_block}-> \
834 {to_block} in {chunks_committed} chunks ({total_events_applied} events applied)."
835 )],
836 AaveUpdateOutcome::Cancelled => vec![format!(
837 "Chain {chain_id} market {market_id}: cancelled (committed chunks stay durable)."
838 )],
839 AaveUpdateOutcome::NeedsBootstrap => vec![format!(
840 "Chain {chain_id} market {market_id} ({market_name}): needs bootstrapping \
841 (last_update_block is None); skipping. Bootstrap the stamp before running."
842 )],
843 AaveUpdateOutcome::DryRun {
844 last_update_block,
845 to_block,
846 } => vec![format!(
847 "Dry run: would advance chain {chain_id} market {market_id} ({market_name}) from \
848 block {last_update_block} to {} (no changes committed).",
849 render_opt_block(*to_block)
850 )],
851 }
852}
853
854fn render_opt_block(block: Option<u64>) -> String {
856 block.map_or_else(|| "None".to_string(), |n| n.to_string())
857}
858
859fn position_lines(
861 user_address: &str,
862 market: &str,
863 chain_id: u64,
864 collateral: &[AavePositionLine],
865 debt: &[AavePositionLine],
866) -> Vec<String> {
867 let mut lines = vec![
868 format!("Aave V3 Positions for {user_address}"),
869 format!("Market: {market} (Chain: {chain_id})"),
870 "=".repeat(60),
871 ];
872 if collateral.is_empty() {
873 lines.push("No collateral positions found.".to_string());
874 } else {
875 lines.push("Collateral Positions:".to_string());
876 lines.push("-".repeat(60));
877 lines.extend(
878 collateral
879 .iter()
880 .map(|p| format!(" {}: {} (scaled)", p.symbol, p.balance)),
881 );
882 }
883 if debt.is_empty() {
884 lines.push("No debt positions found.".to_string());
885 } else {
886 lines.push("Debt Positions:".to_string());
887 lines.push("-".repeat(60));
888 lines.extend(
889 debt.iter()
890 .map(|p| format!(" {}: {} (scaled)", p.symbol, p.balance)),
891 );
892 }
893 lines
894}
895
896fn healed_lines(path: &std::path::Path, report: &HealReport) -> Vec<String> {
898 if matches!(report.old_state, SchemaState::RustOwned { .. }) {
899 return vec![format!(
900 "Database at {} is already Rust-owned; heal is a no-op (no copy, no .bak).",
901 path.display()
902 )];
903 }
904 let total_rows: u64 = report.rows_copied.values().sum();
905 let n_tables = report.rows_copied.len();
906 let mut lines = vec![format!(
907 "Healed database at {}: {total_rows} rows across {n_tables} tables copied; old DB \
908 preserved at {}; new state: {}.",
909 path.display(),
910 report.bak_path.display(),
911 schema_state_label(&report.new_state)
912 )];
913 if !report.warnings.is_empty() {
914 lines.push(format!("Warnings: {}", report.warnings.join("; ")));
915 }
916 lines
917}
918
919#[must_use]
921pub fn cutover_dry_run_line(state: &SchemaState) -> String {
922 let label = schema_state_label(state);
923 match state {
924 SchemaState::LegacyAlembic => format!(
925 "Schema state: {label}. Would cutover from Alembic to Rust ownership (drop \
926 `alembic_version`, stamp `_degenbot_db_schema_version`)."
927 ),
928 SchemaState::RustOwned { .. } => {
929 format!("Schema state: {label}. Already Rust-owned — cutover is a no-op.")
930 }
931 SchemaState::FreshStandalone { .. } => {
932 format!("Schema state: {label}. No legacy history — nothing to cutover.")
933 }
934 SchemaState::Unrecognized => {
935 format!("Schema state: {label}. Unrecognized database (foreign file).")
936 }
937 }
938}
939
940#[must_use]
942pub fn heal_dry_run_line(state: &SchemaState) -> String {
943 let label = schema_state_label(state);
944 match state {
945 SchemaState::LegacyAlembic => format!(
946 "Schema state: {label}. Would heal: rebuild at the Rust head schema, copy all \
947 rows, drop alembic_version, stamp _degenbot_db_schema_version, atomic-swap with \
948 a *.bak backup."
949 ),
950 SchemaState::RustOwned { .. } => {
951 format!("Schema state: {label}. Already Rust-owned — heal is a no-op.")
952 }
953 SchemaState::FreshStandalone { .. } => format!(
954 "Schema state: {label}. Empty file — heal produces a fresh RustOwned DB (0 rows \
955 copied)."
956 ),
957 SchemaState::Unrecognized => {
958 format!(
959 "Schema state: {label}. Unrecognized database (foreign file) — heal would \
960 refuse."
961 )
962 }
963 }
964}
965
966#[must_use]
969pub fn schema_state_label(state: &SchemaState) -> &'static str {
970 match state {
971 SchemaState::LegacyAlembic => "legacy_alembic",
972 SchemaState::FreshStandalone { .. } => "fresh_standalone",
973 SchemaState::RustOwned { .. } => "rust_owned",
974 SchemaState::Unrecognized => "unrecognized",
975 }
976}