Skip to main content

degenbot_cli_core/
report.rs

1//! Typed command reports (ADR-051 D1: execution returns typed results; the
2//! facade renders them — Q1).
3
4use 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/// The typed result of one command execution.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum CommandReport {
16    /// A `database` command report.
17    Database(DatabaseReport),
18    /// An `exchange` command report.
19    Exchange(ExchangeReport),
20    /// A `pool` command report.
21    Pool(PoolReport),
22    /// An `aave` command report.
23    Aave(AaveReport),
24    /// A `fleet` command report.
25    Fleet(FleetReport),
26    /// A `path` command report.
27    Path(PathReport),
28}
29
30impl CommandReport {
31    /// The operator-facing lines the argv facade renders.
32    #[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/// The typed result of the execution entry.
46#[derive(Debug)]
47pub struct CommandOutcome {
48    result: Result<CommandReport, CliError>,
49    /// The process exit code derived at the single `From<&CliError>` site.
50    pub exit_code: ExitCode,
51}
52
53impl CommandOutcome {
54    /// Bundle a result with its derived exit code.
55    #[must_use]
56    pub(crate) const fn new(result: Result<CommandReport, CliError>, exit_code: ExitCode) -> Self {
57        Self { result, exit_code }
58    }
59
60    /// The typed report, when the command completed.
61    #[must_use]
62    pub fn report(&self) -> Option<&CommandReport> {
63        self.result.as_ref().ok()
64    }
65
66    /// The typed failure, when the command did not complete.
67    #[must_use]
68    pub fn error(&self) -> Option<&CliError> {
69        self.result.as_ref().err()
70    }
71}
72
73/// Which dry-run arm produced a [`DatabaseReport::DryRun`].
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum DryRunKind {
76    /// `database cutover --dry-run`.
77    Cutover,
78    /// `database heal --dry-run`.
79    Heal,
80}
81
82/// The outcome of a successful `database cutover` (pre-state derived).
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum CutoverOutcome {
85    /// The DB was Alembic-stamped and is now Rust-owned.
86    Converted,
87    /// The DB was already Rust-owned; the cutover was a no-op.
88    AlreadyRustOwned,
89}
90
91/// The typed result of a `database` command.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum DatabaseReport {
94    /// `backup` completed.
95    BackedUp {
96        /// The source database path.
97        source: PathBuf,
98        /// The written backup path.
99        backup: PathBuf,
100    },
101    /// `reset` recreated the database.
102    Reset {
103        /// The database path.
104        path: PathBuf,
105    },
106    /// `compact` vacuumed the database.
107    Compacted {
108        /// The database path.
109        path: PathBuf,
110    },
111    /// `inspect` read the schema state (writes nothing).
112    Inspected {
113        /// The database path.
114        path: PathBuf,
115        /// The observed schema state.
116        state: SchemaState,
117    },
118    /// `cutover` flipped schema ownership.
119    Cutover {
120        /// The database path.
121        path: PathBuf,
122        /// The observed pre-cutover schema state.
123        state: SchemaState,
124        /// Whether the cutover converted or no-op'd.
125        outcome: CutoverOutcome,
126    },
127    /// `heal` rebuilt the database out-of-place.
128    Healed {
129        /// The database path.
130        path: PathBuf,
131        /// The heal report (rows copied, `.bak` path, warnings).
132        report: HealReport,
133    },
134    /// A `--dry-run` observed the schema state and wrote nothing.
135    DryRun {
136        /// The database path.
137        path: PathBuf,
138        /// The observed schema state.
139        state: SchemaState,
140        /// Which dry-run arm ran.
141        kind: DryRunKind,
142    },
143}
144
145impl DatabaseReport {
146    /// The operator-facing lines for this report (the ported click `echo` text).
147    #[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/// Whether an `exchange activate` flipped the row or found it already active.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum ActivateOutcome {
188    /// The row was newly activated (or re-activated from inactive).
189    Activated,
190    /// The row was already active; nothing was written.
191    AlreadyActive,
192}
193
194/// Whither an `exchange deactivate`.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum DeactivateOutcome {
197    /// The row was newly deactivated.
198    Deactivated,
199    /// The row was already inactive.
200    AlreadyDeactivated,
201    /// The DB has no row for the pair.
202    NoEntry,
203}
204
205/// The typed result of an `exchange` command.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub enum ExchangeReport {
208    /// `exchange activate`.
209    Activated {
210        /// The resolved chain id.
211        chain_id: u64,
212        /// The human chain label.
213        chain_label: &'static str,
214        /// The human DEX label.
215        display_name: &'static str,
216        /// The DEX name slug.
217        dex_slug: &'static str,
218        /// Whether the row flipped or was already active.
219        outcome: ActivateOutcome,
220    },
221    /// `exchange deactivate`.
222    Deactivated {
223        /// The resolved chain id.
224        chain_id: u64,
225        /// The human chain label.
226        chain_label: &'static str,
227        /// The human DEX label.
228        display_name: &'static str,
229        /// The DEX name slug.
230        dex_slug: &'static str,
231        /// The deactivation outcome.
232        outcome: DeactivateOutcome,
233    },
234}
235
236impl ExchangeReport {
237    /// The operator-facing lines for this report.
238    #[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/// The typed result of a `pool` command.
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub enum PoolReport {
280    /// `pool update` advanced the chain.
281    Updated {
282        /// The chain advanced.
283        chain_id: i64,
284        /// The first block processed.
285        from_block: u64,
286        /// The last block advanced to.
287        to_block: u64,
288        /// Chunks committed.
289        chunks_committed: usize,
290        /// Pool rows written.
291        total_pools_written: usize,
292        /// Per-pool liquidity applies.
293        total_liquidity_applies: usize,
294    },
295    /// `pool update` was cooperatively cancelled; committed chunks stay durable.
296    UpdateCancelled {
297        /// The chain.
298        chain_id: i64,
299    },
300    /// `pool verify` compared the committed map against on-chain truth.
301    Verified {
302        /// The pool identifier.
303        pool: String,
304        /// The family.
305        family: PoolFamily,
306        /// The block the truth was read at.
307        block_number: u64,
308        /// The named divergences (empty = GREEN).
309        divergences: Vec<degenbot_pool_updater::LiquidityDivergence>,
310    },
311}
312
313impl PoolReport {
314    /// The operator-facing lines for this report.
315    #[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/// The typed result of a `fleet` command (ADR-051 D6).
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub enum FleetReport {
346    /// The live posture echo: the six `cordon_*` values plus `posture`,
347    /// rendered as one compact JSON object with sorted keys (the Python
348    /// `json.dumps(effective, sort_keys=True)` line).
349    Posture {
350        /// The rendered effective policy (`{}` when the host echoed none).
351        effective: String,
352    },
353}
354
355impl FleetReport {
356    /// The operator-facing lines for this report.
357    #[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/// The typed result of a `path` command (ADR-051 D6).
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub enum PathReport {
368    /// `path add` enqueued the path.
369    Added {
370        /// The host's `detail` receipt.
371        detail: String,
372    },
373    /// `path discover` completed a bounded sweep.
374    Discovered {
375        /// The host's `detail` receipt.
376        detail: String,
377    },
378}
379
380impl PathReport {
381    /// The operator-facing lines for this report.
382    #[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
390/// The green / red `pool verify` text.
391fn 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/// One `aave update` market's outcome.
438#[derive(Debug, Clone, PartialEq, Eq)]
439pub enum AaveUpdateOutcome {
440    /// The market advanced.
441    Advanced {
442        /// First block processed.
443        from_block: u64,
444        /// Last block advanced to.
445        to_block: u64,
446        /// Chunks committed.
447        chunks_committed: usize,
448        /// Events applied.
449        total_events_applied: usize,
450    },
451    /// The market's run was cooperatively cancelled.
452    Cancelled,
453    /// The market has no `last_update_block`; it is skipped (must be bootstrapped).
454    NeedsBootstrap,
455    /// `--dry-run`: the would-be advance, with nothing committed.
456    DryRun {
457        /// The market's `last_update_block`.
458        last_update_block: i64,
459        /// The resolved target (`None` = chain tip).
460        to_block: Option<u64>,
461    },
462}
463
464/// One `aave update` market row.
465#[derive(Debug, Clone, PartialEq, Eq)]
466pub struct AaveUpdateEntry {
467    /// The chain.
468    pub chain_id: i64,
469    /// The market id.
470    pub market_id: i64,
471    /// The market name.
472    pub market_name: String,
473    /// The outcome.
474    pub outcome: AaveUpdateOutcome,
475}
476
477/// One position row (scaled balance + token symbol).
478#[derive(Debug, Clone, PartialEq, Eq)]
479pub struct AavePositionLine {
480    /// The underlying token symbol (`Unknown` when unresolved).
481    pub symbol: String,
482    /// The scaled balance.
483    pub balance: U256,
484}
485
486/// The typed result of an `aave` command.
487#[derive(Debug, Clone, PartialEq, Eq)]
488pub enum AaveReport {
489    /// `aave activate`.
490    Activated {
491        /// The chain id.
492        chain_id: u64,
493        /// The human chain label.
494        chain_label: &'static str,
495        /// The market id.
496        market_id: i64,
497        /// The on-chain market name.
498        market_name: String,
499        /// Whether the market was newly created.
500        created: bool,
501    },
502    /// `aave deactivate`.
503    Deactivated {
504        /// The chain id.
505        chain_id: u64,
506        /// The market id, when a row was found.
507        market_id: Option<i64>,
508        /// The outcome.
509        outcome: DeactivateOutcome,
510    },
511    /// `aave update`.
512    Updated {
513        /// Per-market outcomes.
514        entries: Vec<AaveUpdateEntry>,
515    },
516    /// `aave position show`.
517    Position {
518        /// The user address (checksummed).
519        user_address: String,
520        /// The market name.
521        market: String,
522        /// The chain id.
523        chain_id: u64,
524        /// Collateral positions.
525        collateral: Vec<AavePositionLine>,
526        /// Debt positions.
527        debt: Vec<AavePositionLine>,
528    },
529    /// `aave position show` found no market.
530    PositionNoMarket {
531        /// The market name.
532        market: String,
533        /// The chain id.
534        chain_id: u64,
535    },
536    /// `aave position show` found no user row.
537    PositionNoUser {
538        /// The user address (checksummed).
539        user_address: String,
540        /// The market name.
541        market: String,
542        /// The chain id.
543        chain_id: u64,
544    },
545}
546
547impl AaveReport {
548    /// The operator-facing lines for this report.
549    #[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
602/// The human chain label used in the aave report lines.
603fn 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
611/// The lines for one `aave update` market.
612fn 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
647/// Render an optional resolved block the way Python's `{value!r}` does.
648fn render_opt_block(block: Option<u64>) -> String {
649    block.map_or_else(|| "None".to_string(), |n| n.to_string())
650}
651
652/// The ported `aave position show` body.
653fn 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
689/// The ported `heal` success / no-op text.
690fn 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/// The ported `database cutover --dry-run` line for `state`.
713#[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/// The ported `database heal --dry-run` line for `state`.
734#[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/// The Python-compatible label for a [`SchemaState`] (mirrors the
760/// `db_inspect_schema_state` return values).
761#[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}