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;
12use crate::strategy::StrategyFacetDescriptor;
13
14/// The typed result of one command execution.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum CommandReport {
17    /// A `database` command report.
18    Database(DatabaseReport),
19    /// An `exchange` command report.
20    Exchange(ExchangeReport),
21    /// A `pool` command report.
22    Pool(PoolReport),
23    /// An `aave` command report.
24    Aave(AaveReport),
25    /// A `fleet` command report.
26    Fleet(FleetReport),
27    /// A `path` command report.
28    Path(PathReport),
29    /// A `strategy` command report (ADR-055 facets).
30    Strategy(StrategyReport),
31}
32
33impl CommandReport {
34    /// The operator-facing lines the argv facade renders.
35    #[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/// The typed result of the execution entry.
50#[derive(Debug)]
51pub struct CommandOutcome {
52    result: Result<CommandReport, CliError>,
53    /// The process exit code derived at the single `From<&CliError>` site.
54    pub exit_code: ExitCode,
55}
56
57impl CommandOutcome {
58    /// Bundle a result with its derived exit code.
59    #[must_use]
60    pub(crate) const fn new(result: Result<CommandReport, CliError>, exit_code: ExitCode) -> Self {
61        Self { result, exit_code }
62    }
63
64    /// The typed report, when the command completed.
65    #[must_use]
66    pub fn report(&self) -> Option<&CommandReport> {
67        self.result.as_ref().ok()
68    }
69
70    /// The typed failure, when the command did not complete.
71    #[must_use]
72    pub fn error(&self) -> Option<&CliError> {
73        self.result.as_ref().err()
74    }
75}
76
77/// Which dry-run arm produced a [`DatabaseReport::DryRun`].
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum DryRunKind {
80    /// `database cutover --dry-run`.
81    Cutover,
82    /// `database heal --dry-run`.
83    Heal,
84}
85
86/// The outcome of a successful `database cutover` (pre-state derived).
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum CutoverOutcome {
89    /// The DB was Alembic-stamped and is now Rust-owned.
90    Converted,
91    /// The DB was already Rust-owned; the cutover was a no-op.
92    AlreadyRustOwned,
93}
94
95/// The typed result of a `database` command.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum DatabaseReport {
98    /// `backup` completed.
99    BackedUp {
100        /// The source database path.
101        source: PathBuf,
102        /// The written backup path.
103        backup: PathBuf,
104    },
105    /// `reset` recreated the database.
106    Reset {
107        /// The database path.
108        path: PathBuf,
109    },
110    /// `compact` vacuumed the database.
111    Compacted {
112        /// The database path.
113        path: PathBuf,
114    },
115    /// `inspect` read the schema state (writes nothing).
116    Inspected {
117        /// The database path.
118        path: PathBuf,
119        /// The observed schema state.
120        state: SchemaState,
121    },
122    /// `cutover` flipped schema ownership.
123    Cutover {
124        /// The database path.
125        path: PathBuf,
126        /// The observed pre-cutover schema state.
127        state: SchemaState,
128        /// Whether the cutover converted or no-op'd.
129        outcome: CutoverOutcome,
130    },
131    /// `heal` rebuilt the database out-of-place.
132    Healed {
133        /// The database path.
134        path: PathBuf,
135        /// The heal report (rows copied, `.bak` path, warnings).
136        report: HealReport,
137    },
138    /// A `--dry-run` observed the schema state and wrote nothing.
139    DryRun {
140        /// The database path.
141        path: PathBuf,
142        /// The observed schema state.
143        state: SchemaState,
144        /// Which dry-run arm ran.
145        kind: DryRunKind,
146    },
147}
148
149impl DatabaseReport {
150    /// The operator-facing lines for this report (the ported click `echo` text).
151    #[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/// Whether an `exchange activate` flipped the row or found it already active.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum ActivateOutcome {
192    /// The row was newly activated (or re-activated from inactive).
193    Activated,
194    /// The row was already active; nothing was written.
195    AlreadyActive,
196}
197
198/// Whither an `exchange deactivate`.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum DeactivateOutcome {
201    /// The row was newly deactivated.
202    Deactivated,
203    /// The row was already inactive.
204    AlreadyDeactivated,
205    /// The DB has no row for the pair.
206    NoEntry,
207}
208
209/// Whether an `exchange list` row's DB entry exists, and its active state.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum ExchangeActiveState {
212    /// The DB row exists with `active = true`.
213    Active,
214    /// The DB row exists with `active = false`.
215    Inactive,
216    /// The DB has no row for the pair (never activated).
217    NoEntry,
218}
219
220impl ExchangeActiveState {
221    /// The operator-facing state word in the `exchange list` lines.
222    #[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/// One `exchange list` row: a supported `(chain, DEX)` pair and its DB state.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct ExchangeListRow {
235    /// The chain id.
236    pub chain_id: u64,
237    /// The human chain label.
238    pub chain_label: &'static str,
239    /// The human DEX label.
240    pub display_name: &'static str,
241    /// The DEX name slug.
242    pub dex_slug: &'static str,
243    /// The exchange factory (V4: the `PoolManager`), checksummed.
244    pub factory: String,
245    /// The DB active state.
246    pub state: ExchangeActiveState,
247}
248
249/// The typed result of an `exchange` command.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub enum ExchangeReport {
252    /// `exchange activate`.
253    Activated {
254        /// The resolved chain id.
255        chain_id: u64,
256        /// The human chain label.
257        chain_label: &'static str,
258        /// The human DEX label.
259        display_name: &'static str,
260        /// The DEX name slug.
261        dex_slug: &'static str,
262        /// Whether the row flipped or was already active.
263        outcome: ActivateOutcome,
264    },
265    /// `exchange deactivate`.
266    Deactivated {
267        /// The resolved chain id.
268        chain_id: u64,
269        /// The human chain label.
270        chain_label: &'static str,
271        /// The human DEX label.
272        display_name: &'static str,
273        /// The DEX name slug.
274        dex_slug: &'static str,
275        /// The deactivation outcome.
276        outcome: DeactivateOutcome,
277    },
278    /// `exchange list`.
279    Listed {
280        /// One row per supported `(chain, DEX)` pair, in declaration order.
281        rows: Vec<ExchangeListRow>,
282    },
283}
284
285impl ExchangeReport {
286    /// The operator-facing lines for this report.
287    #[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/// The typed result of a `pool` command.
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub enum PoolReport {
341    /// `pool update` advanced the chain.
342    Updated {
343        /// The chain advanced.
344        chain_id: i64,
345        /// The first block processed.
346        from_block: u64,
347        /// The last block advanced to.
348        to_block: u64,
349        /// Chunks committed.
350        chunks_committed: usize,
351        /// Pool rows written.
352        total_pools_written: usize,
353        /// Per-pool liquidity applies.
354        total_liquidity_applies: usize,
355    },
356    /// `pool update` was cooperatively cancelled; committed chunks stay durable.
357    UpdateCancelled {
358        /// The chain.
359        chain_id: i64,
360    },
361    /// `pool verify` compared the committed map against on-chain truth.
362    Verified {
363        /// The pool identifier.
364        pool: String,
365        /// The family.
366        family: PoolFamily,
367        /// The block the truth was read at.
368        block_number: u64,
369        /// The named divergences (empty = GREEN).
370        divergences: Vec<degenbot_pool_updater::LiquidityDivergence>,
371    },
372}
373
374impl PoolReport {
375    /// The operator-facing lines for this report.
376    #[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/// The typed result of a `fleet` command (ADR-051 D6).
405#[derive(Debug, Clone, PartialEq, Eq)]
406pub enum FleetReport {
407    /// The live posture echo: the six `cordon_*` values plus `posture`,
408    /// rendered as one compact JSON object with sorted keys (the Python
409    /// `json.dumps(effective, sort_keys=True)` line).
410    Posture {
411        /// The rendered effective policy (`{}` when the host echoed none).
412        effective: String,
413    },
414}
415
416impl FleetReport {
417    /// The operator-facing lines for this report.
418    #[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/// The typed result of a `path` command (ADR-051 D6).
427#[derive(Debug, Clone, PartialEq, Eq)]
428pub enum PathReport {
429    /// `path add` enqueued the path.
430    Added {
431        /// The host's `detail` receipt.
432        detail: String,
433    },
434    /// `path discover` completed a bounded sweep.
435    Discovered {
436        /// The host's `detail` receipt.
437        detail: String,
438    },
439}
440
441impl PathReport {
442    /// The operator-facing lines for this report.
443    #[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/// The typed result of a `strategy` command.
452#[derive(Debug, Clone, PartialEq, Eq)]
453pub enum StrategyReport {
454    /// `strategy list`: every declared facet.
455    Listed {
456        /// One descriptor per facet, in declaration order.
457        rows: Vec<StrategyFacetDescriptor>,
458    },
459    /// `strategy show`: one facet's descriptor plus its activation posture.
460    Shown {
461        /// The facet descriptor.
462        descriptor: StrategyFacetDescriptor,
463        /// The facet's activation + endpoint posture (`None` when no config
464        /// file could be read).
465        activation: Option<crate::strategy::EndpointSummary>,
466    },
467    /// `strategy activate`: the facet was activated with the settled posture.
468    Activated {
469        /// The activated facet.
470        facet: crate::strategy::StrategyFacet,
471        /// The settled endpoint posture.
472        summary: crate::strategy::EndpointSummary,
473        /// Whether the write applies or an env var shadows it.
474        outcome: crate::strategy::MutationOutcome,
475    },
476    /// `strategy deactivate`: the facet was deactivated.
477    Deactivated {
478        /// The deactivated facet.
479        facet: crate::strategy::StrategyFacet,
480        /// Whether the write applies or an env var shadows it.
481        outcome: crate::strategy::MutationOutcome,
482    },
483    /// `strategy set`: a facet key's override was written.
484    Set {
485        /// The mutated facet.
486        facet: crate::strategy::StrategyFacet,
487        /// The written key's field name.
488        key: &'static str,
489        /// Whether the write applies or an env var shadows it.
490        outcome: crate::strategy::MutationOutcome,
491    },
492    /// `strategy default` / `strategy remove`: the override was dropped so the
493    /// schema default applies again.
494    Defaulted {
495        /// The mutated facet.
496        facet: crate::strategy::StrategyFacet,
497        /// The restored key's field name.
498        key: &'static str,
499    },
500}
501
502impl StrategyReport {
503    /// The operator-facing lines for this report.
504    #[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
573/// One line describing a facet's settled endpoint posture.
574fn 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
587/// The loud line when the env layer shadows a written key.
588fn 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
597/// The green / red `pool verify` text.
598fn 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/// One `aave update` market's outcome.
645#[derive(Debug, Clone, PartialEq, Eq)]
646pub enum AaveUpdateOutcome {
647    /// The market advanced.
648    Advanced {
649        /// First block processed.
650        from_block: u64,
651        /// Last block advanced to.
652        to_block: u64,
653        /// Chunks committed.
654        chunks_committed: usize,
655        /// Events applied.
656        total_events_applied: usize,
657    },
658    /// The market's run was cooperatively cancelled.
659    Cancelled,
660    /// The market has no `last_update_block`; it is skipped (must be bootstrapped).
661    NeedsBootstrap,
662    /// `--dry-run`: the would-be advance, with nothing committed.
663    DryRun {
664        /// The market's `last_update_block`.
665        last_update_block: i64,
666        /// The resolved target (`None` = chain tip).
667        to_block: Option<u64>,
668    },
669}
670
671/// One `aave update` market row.
672#[derive(Debug, Clone, PartialEq, Eq)]
673pub struct AaveUpdateEntry {
674    /// The chain.
675    pub chain_id: i64,
676    /// The market id.
677    pub market_id: i64,
678    /// The market name.
679    pub market_name: String,
680    /// The outcome.
681    pub outcome: AaveUpdateOutcome,
682}
683
684/// One position row (scaled balance + token symbol).
685#[derive(Debug, Clone, PartialEq, Eq)]
686pub struct AavePositionLine {
687    /// The underlying token symbol (`Unknown` when unresolved).
688    pub symbol: String,
689    /// The scaled balance.
690    pub balance: U256,
691}
692
693/// The typed result of an `aave` command.
694#[derive(Debug, Clone, PartialEq, Eq)]
695pub enum AaveReport {
696    /// `aave activate`.
697    Activated {
698        /// The chain id.
699        chain_id: u64,
700        /// The human chain label.
701        chain_label: &'static str,
702        /// The market id.
703        market_id: i64,
704        /// The on-chain market name.
705        market_name: String,
706        /// Whether the market was newly created.
707        created: bool,
708    },
709    /// `aave deactivate`.
710    Deactivated {
711        /// The chain id.
712        chain_id: u64,
713        /// The market id, when a row was found.
714        market_id: Option<i64>,
715        /// The outcome.
716        outcome: DeactivateOutcome,
717    },
718    /// `aave update`.
719    Updated {
720        /// Per-market outcomes.
721        entries: Vec<AaveUpdateEntry>,
722    },
723    /// `aave position show`.
724    Position {
725        /// The user address (checksummed).
726        user_address: String,
727        /// The market name.
728        market: String,
729        /// The chain id.
730        chain_id: u64,
731        /// Collateral positions.
732        collateral: Vec<AavePositionLine>,
733        /// Debt positions.
734        debt: Vec<AavePositionLine>,
735    },
736    /// `aave position show` found no market.
737    PositionNoMarket {
738        /// The market name.
739        market: String,
740        /// The chain id.
741        chain_id: u64,
742    },
743    /// `aave position show` found no user row.
744    PositionNoUser {
745        /// The user address (checksummed).
746        user_address: String,
747        /// The market name.
748        market: String,
749        /// The chain id.
750        chain_id: u64,
751    },
752}
753
754impl AaveReport {
755    /// The operator-facing lines for this report.
756    #[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
809/// The human chain label used in the aave report lines.
810fn 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
818/// The lines for one `aave update` market.
819fn 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
854/// Render an optional resolved block the way Python's `{value!r}` does.
855fn render_opt_block(block: Option<u64>) -> String {
856    block.map_or_else(|| "None".to_string(), |n| n.to_string())
857}
858
859/// The ported `aave position show` body.
860fn 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
896/// The ported `heal` success / no-op text.
897fn 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/// The ported `database cutover --dry-run` line for `state`.
920#[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/// The ported `database heal --dry-run` line for `state`.
941#[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/// The Python-compatible label for a [`SchemaState`] (mirrors the
967/// `db_inspect_schema_state` return values).
968#[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}