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    /// A `config` command report.
32    Config(ConfigReport),
33}
34
35impl CommandReport {
36    /// The operator-facing lines the argv facade renders.
37    #[must_use]
38    pub fn render_lines(&self) -> Vec<String> {
39        match self {
40            Self::Database(report) => report.render_lines(),
41            Self::Exchange(report) => report.render_lines(),
42            Self::Pool(report) => report.render_lines(),
43            Self::Aave(report) => report.render_lines(),
44            Self::Fleet(report) => report.render_lines(),
45            Self::Path(report) => report.render_lines(),
46            Self::Strategy(report) => report.render_lines(),
47            Self::Config(report) => report.render_lines(),
48        }
49    }
50}
51
52/// One resolved (or absent) driver-domain value, as `config show` renders it.
53///
54/// The key is the operator's own spelling: a declared key
55/// (`session.chain_id`, `database.path`), a per-chain endpoint entry
56/// (`nodes.ws[8453]`), or a whole transport's explicit slot (`nodes.ws`).
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ConfigValue {
59    /// The key as the operator writes it.
60    pub key: String,
61    /// The value, verbatim; `(unresolved)` is carried by the absent source
62    /// rather than a sentinel string.
63    pub value: String,
64    /// The layer that supplied the value, or `None` when no layer did.
65    pub source: Option<degenbot_config::Source>,
66}
67
68impl ConfigValue {
69    /// A value with its winning layer.
70    #[must_use]
71    pub fn new(key: &str, value: String, source: degenbot_config::Source) -> Self {
72        Self {
73            key: key.to_string(),
74            value,
75            source: Some(source),
76        }
77    }
78
79    /// A key no layer supplied: reported, never guessed and never omitted.
80    #[must_use]
81    pub fn unresolved(key: &str) -> Self {
82        Self {
83            key: key.to_string(),
84            value: String::new(),
85            source: None,
86        }
87    }
88
89    /// The `key = value` line (the file view). The value is redacted at
90    /// render time: the file keeps the operator's bytes, but a rendered line
91    /// never prints a credential (ADR-062 D12).
92    #[must_use]
93    pub fn line(&self) -> String {
94        match self.source {
95            Some(_) => format!(
96                "{} = {}",
97                self.key,
98                degenbot_config::redact_uri(&self.value)
99            ),
100            None => format!("{} = (unresolved)", self.key),
101        }
102    }
103
104    /// The `key = value (source)` line (`config show --resolved`), redacted.
105    #[must_use]
106    pub fn resolved_line(&self) -> String {
107        match self.source {
108            Some(source) => format!(
109                "{} = {} ({source})",
110                self.key,
111                degenbot_config::redact_uri(&self.value)
112            ),
113            None => format!("{} = (unresolved)", self.key),
114        }
115    }
116}
117
118/// The typed result of a `config` command.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum ConfigReport {
121    /// `config show`: the driver-domain values, annotated with their winning
122    /// layer when `resolved` was asked for.
123    Shown {
124        /// The config file in play, absent when no location resolves.
125        file: Option<PathBuf>,
126        /// The values, in the operator's key order.
127        values: Vec<ConfigValue>,
128        /// Whether each line carries its winning layer (`--resolved`).
129        resolved: bool,
130    },
131    /// `config path`: the config file the mutating arms read and write.
132    Path(PathBuf),
133    /// `config get <key>`: one resolved value and its winning layer (or the
134    /// absent layer for an unresolved key).
135    Got {
136        /// The key as the operator spelled it.
137        key: String,
138        /// The resolved value, verbatim (redacted at render time).
139        value: String,
140        /// The layer that supplied it, or `None` when none did.
141        source: Option<degenbot_config::Source>,
142    },
143    /// `config set <key> <value>`: the override was written.
144    Set {
145        /// The key as the operator spelled it.
146        key: String,
147        /// The value written, verbatim (redacted at render time).
148        value: String,
149        /// Whether the write applies or an env var shadows it.
150        outcome: crate::strategy::MutationOutcome,
151    },
152    /// `config unset <key>`: the override was dropped.
153    Unset {
154        /// The key as the operator spelled it.
155        key: String,
156        /// Whether an env var still supplies the key at load time.
157        outcome: crate::strategy::MutationOutcome,
158    },
159}
160
161impl ConfigReport {
162    /// The operator-facing lines for this report.
163    #[must_use]
164    pub fn render_lines(&self) -> Vec<String> {
165        match self {
166            Self::Shown {
167                file,
168                values,
169                resolved,
170            } => {
171                // The file in play is also the mutating arms' write target, so
172                // an absent file is named as such rather than presented as a
173                // file the values were read from.
174                let mut lines = vec![match file {
175                    Some(path) if path.exists() => format!("config = {}", path.display()),
176                    Some(path) => format!("config = {} (not yet created)", path.display()),
177                    None => "config = (no config file location)".to_string(),
178                }];
179                lines.extend(values.iter().map(|value| {
180                    if *resolved {
181                        value.resolved_line()
182                    } else {
183                        value.line()
184                    }
185                }));
186                lines
187            }
188            Self::Path(path) => vec![path.display().to_string()],
189            Self::Got { key, value, source } => vec![match source {
190                Some(source) => {
191                    format!("{key} = {} ({source})", degenbot_config::redact_uri(value))
192                }
193                None => format!("{key} = (unresolved)"),
194            }],
195            Self::Set {
196                key,
197                value,
198                outcome,
199            } => vec![
200                format!("set {key} = {}", degenbot_config::redact_uri(value)),
201                shadow_line(outcome),
202            ],
203            Self::Unset { key, outcome } => {
204                vec![format!("removed {key}"), shadow_line(outcome)]
205            }
206        }
207    }
208}
209
210/// The typed result of the execution entry.
211#[derive(Debug)]
212pub struct CommandOutcome {
213    result: Result<CommandReport, CliError>,
214    /// The process exit code derived at the single `From<&CliError>` site.
215    pub exit_code: ExitCode,
216}
217
218impl CommandOutcome {
219    /// Bundle a result with its derived exit code.
220    #[must_use]
221    pub(crate) const fn new(result: Result<CommandReport, CliError>, exit_code: ExitCode) -> Self {
222        Self { result, exit_code }
223    }
224
225    /// The typed report, when the command completed.
226    #[must_use]
227    pub fn report(&self) -> Option<&CommandReport> {
228        self.result.as_ref().ok()
229    }
230
231    /// The typed failure, when the command did not complete.
232    #[must_use]
233    pub fn error(&self) -> Option<&CliError> {
234        self.result.as_ref().err()
235    }
236}
237
238/// Which dry-run arm produced a [`DatabaseReport::DryRun`].
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum DryRunKind {
241    /// `database cutover --dry-run`.
242    Cutover,
243    /// `database heal --dry-run`.
244    Heal,
245}
246
247/// The outcome of a successful `database cutover` (pre-state derived).
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum CutoverOutcome {
250    /// The DB was Alembic-stamped and is now Rust-owned.
251    Converted,
252    /// The DB was already Rust-owned; the cutover was a no-op.
253    AlreadyRustOwned,
254}
255
256/// The typed result of a `database` command.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub enum DatabaseReport {
259    /// `backup` completed.
260    BackedUp {
261        /// The source database path.
262        source: PathBuf,
263        /// The written backup path.
264        backup: PathBuf,
265    },
266    /// `reset` recreated the database.
267    Reset {
268        /// The database path.
269        path: PathBuf,
270    },
271    /// `compact` vacuumed the database.
272    Compacted {
273        /// The database path.
274        path: PathBuf,
275    },
276    /// `inspect` read the schema state (writes nothing).
277    Inspected {
278        /// The database path.
279        path: PathBuf,
280        /// The observed schema state.
281        state: SchemaState,
282    },
283    /// `cutover` flipped schema ownership.
284    Cutover {
285        /// The database path.
286        path: PathBuf,
287        /// The observed pre-cutover schema state.
288        state: SchemaState,
289        /// Whether the cutover converted or no-op'd.
290        outcome: CutoverOutcome,
291    },
292    /// `heal` rebuilt the database out-of-place.
293    Healed {
294        /// The database path.
295        path: PathBuf,
296        /// The heal report (rows copied, `.bak` path, warnings).
297        report: HealReport,
298    },
299    /// A `--dry-run` observed the schema state and wrote nothing.
300    DryRun {
301        /// The database path.
302        path: PathBuf,
303        /// The observed schema state.
304        state: SchemaState,
305        /// Which dry-run arm ran.
306        kind: DryRunKind,
307    },
308}
309
310impl DatabaseReport {
311    /// The operator-facing lines for this report (the ported click `echo` text).
312    #[must_use]
313    pub fn render_lines(&self) -> Vec<String> {
314        match self {
315            Self::BackedUp { backup, .. } => {
316                vec![format!("Backed up SQLite database to {}", backup.display())]
317            }
318            Self::Reset { path } => {
319                vec![format!(
320                    "Initialized new SQLite database at {}",
321                    path.display()
322                )]
323            }
324            Self::Compacted { path } => {
325                vec![format!("Compacted SQLite database at {}", path.display())]
326            }
327            Self::Inspected { state, .. } => {
328                vec![format!("Schema state: {}.", schema_state_label(state))]
329            }
330            Self::Cutover { path, outcome, .. } => match outcome {
331                CutoverOutcome::Converted => vec![format!(
332                    "Cut over database at {} from Alembic to Rust ownership (the \
333                     `alembic_version` table was dropped and `_degenbot_db_schema_version` \
334                     was stamped).",
335                    path.display()
336                )],
337                CutoverOutcome::AlreadyRustOwned => {
338                    vec!["The database is already Rust-owned; cutover was a no-op.".to_string()]
339                }
340            },
341            Self::Healed { path, report } => healed_lines(path, report),
342            Self::DryRun { state, kind, .. } => vec![match kind {
343                DryRunKind::Cutover => cutover_dry_run_line(state),
344                DryRunKind::Heal => heal_dry_run_line(state),
345            }],
346        }
347    }
348}
349
350/// Whether an `exchange activate` flipped the row or found it already active.
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub enum ActivateOutcome {
353    /// The row was newly activated (or re-activated from inactive).
354    Activated,
355    /// The row was already active; nothing was written.
356    AlreadyActive,
357}
358
359/// Whither an `exchange deactivate`.
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum DeactivateOutcome {
362    /// The row was newly deactivated.
363    Deactivated,
364    /// The row was already inactive.
365    AlreadyDeactivated,
366    /// The DB has no row for the pair.
367    NoEntry,
368}
369
370/// Whether an `exchange list` row's DB entry exists, and its active state.
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum ExchangeActiveState {
373    /// The DB row exists with `active = true`.
374    Active,
375    /// The DB row exists with `active = false`.
376    Inactive,
377    /// The DB has no row for the pair (never activated).
378    NoEntry,
379}
380
381impl ExchangeActiveState {
382    /// The operator-facing state word in the `exchange list` lines.
383    #[must_use]
384    pub const fn as_str(self) -> &'static str {
385        match self {
386            Self::Active => "active",
387            Self::Inactive => "inactive",
388            Self::NoEntry => "not in database",
389        }
390    }
391}
392
393/// One `exchange list` row: a supported `(chain, DEX)` pair and its DB state.
394#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct ExchangeListRow {
396    /// The chain id.
397    pub chain_id: u64,
398    /// The human chain label.
399    pub chain_label: &'static str,
400    /// The human DEX label.
401    pub display_name: &'static str,
402    /// The DEX name slug.
403    pub dex_slug: &'static str,
404    /// The exchange factory (V4: the `PoolManager`), checksummed.
405    pub factory: String,
406    /// The DB active state.
407    pub state: ExchangeActiveState,
408}
409
410/// The typed result of an `exchange` command.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub enum ExchangeReport {
413    /// `exchange activate`.
414    Activated {
415        /// The resolved chain id.
416        chain_id: u64,
417        /// The human chain label.
418        chain_label: &'static str,
419        /// The human DEX label.
420        display_name: &'static str,
421        /// The DEX name slug.
422        dex_slug: &'static str,
423        /// Whether the row flipped or was already active.
424        outcome: ActivateOutcome,
425    },
426    /// `exchange deactivate`.
427    Deactivated {
428        /// The resolved chain id.
429        chain_id: u64,
430        /// The human chain label.
431        chain_label: &'static str,
432        /// The human DEX label.
433        display_name: &'static str,
434        /// The DEX name slug.
435        dex_slug: &'static str,
436        /// The deactivation outcome.
437        outcome: DeactivateOutcome,
438    },
439    /// `exchange list`.
440    Listed {
441        /// One row per supported `(chain, DEX)` pair, in declaration order.
442        rows: Vec<ExchangeListRow>,
443    },
444}
445
446impl ExchangeReport {
447    /// The operator-facing lines for this report.
448    #[must_use]
449    pub fn render_lines(&self) -> Vec<String> {
450        match self {
451            Self::Activated {
452                chain_id,
453                chain_label,
454                display_name,
455                outcome,
456                ..
457            } => match outcome {
458                ActivateOutcome::Activated => vec![format!(
459                    "Activated {display_name} on {chain_label} (chain ID {chain_id})."
460                )],
461                ActivateOutcome::AlreadyActive => {
462                    vec!["Exchange is already activated.".to_string()]
463                }
464            },
465            Self::Deactivated {
466                chain_id,
467                chain_label,
468                display_name,
469                outcome,
470                ..
471            } => match outcome {
472                DeactivateOutcome::Deactivated => vec![format!(
473                    "Deactivated {display_name} on {chain_label} (chain ID {chain_id})."
474                )],
475                DeactivateOutcome::AlreadyDeactivated => {
476                    vec!["Exchange is already deactivated.".to_string()]
477                }
478                DeactivateOutcome::NoEntry => vec![format!(
479                    "The database has no entry for {display_name} on {chain_label} \
480                     (chain ID {chain_id})."
481                )],
482            },
483            Self::Listed { rows } => rows
484                .iter()
485                .map(|row| {
486                    format!(
487                        "{} on {} (chain ID {}): {}",
488                        row.display_name,
489                        row.chain_label,
490                        row.chain_id,
491                        row.state.as_str()
492                    )
493                })
494                .collect(),
495        }
496    }
497}
498
499/// The typed result of a `pool` command.
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub enum PoolReport {
502    /// `pool update` advanced the chain.
503    Updated {
504        /// The chain advanced.
505        chain_id: i64,
506        /// The first block processed.
507        from_block: u64,
508        /// The last block advanced to.
509        to_block: u64,
510        /// Chunks committed.
511        chunks_committed: usize,
512        /// Pool rows written.
513        total_pools_written: usize,
514        /// Per-pool liquidity applies.
515        total_liquidity_applies: usize,
516    },
517    /// `pool update` was cooperatively cancelled; committed chunks stay durable.
518    UpdateCancelled {
519        /// The chain.
520        chain_id: i64,
521    },
522    /// `pool verify` compared the committed map against on-chain truth.
523    Verified {
524        /// The pool identifier.
525        pool: String,
526        /// The family.
527        family: PoolFamily,
528        /// The block the truth was read at.
529        block_number: u64,
530        /// The named divergences (empty = GREEN).
531        divergences: Vec<degenbot_pool_updater::LiquidityDivergence>,
532    },
533}
534
535impl PoolReport {
536    /// The operator-facing lines for this report.
537    #[must_use]
538    pub fn render_lines(&self) -> Vec<String> {
539        match self {
540            Self::Updated {
541                chain_id,
542                from_block,
543                to_block,
544                chunks_committed,
545                total_pools_written,
546                total_liquidity_applies,
547            } => vec![format!(
548                "Chain {chain_id}: advanced {from_block}->{to_block} in {chunks_committed} \
549                 chunks ({total_pools_written} pools written, {total_liquidity_applies} \
550                 liquidity applies)."
551            )],
552            Self::UpdateCancelled { chain_id } => vec![format!(
553                "Chain {chain_id}: cancelled (committed chunks stay durable)."
554            )],
555            Self::Verified {
556                pool,
557                family,
558                block_number,
559                divergences,
560            } => verification_lines(pool, *family, *block_number, divergences),
561        }
562    }
563}
564
565/// The typed result of a `fleet` command (ADR-051 D6).
566#[derive(Debug, Clone, PartialEq, Eq)]
567pub enum FleetReport {
568    /// The live posture echo: the six `cordon_*` values plus `posture`,
569    /// rendered as one compact JSON object with sorted keys (the Python
570    /// `json.dumps(effective, sort_keys=True)` line).
571    Posture {
572        /// The rendered effective policy (`{}` when the host echoed none).
573        effective: String,
574    },
575}
576
577impl FleetReport {
578    /// The operator-facing lines for this report.
579    #[must_use]
580    pub fn render_lines(&self) -> Vec<String> {
581        match self {
582            Self::Posture { effective } => vec![effective.clone()],
583        }
584    }
585}
586
587/// The typed result of a `path` command (ADR-051 D6).
588#[derive(Debug, Clone, PartialEq, Eq)]
589pub enum PathReport {
590    /// `path add` enqueued the path.
591    Added {
592        /// The host's `detail` receipt.
593        detail: String,
594    },
595    /// `path discover` completed a bounded sweep.
596    Discovered {
597        /// The host's `detail` receipt.
598        detail: String,
599    },
600}
601
602impl PathReport {
603    /// The operator-facing lines for this report.
604    #[must_use]
605    pub fn render_lines(&self) -> Vec<String> {
606        match self {
607            Self::Added { detail } | Self::Discovered { detail } => vec![detail.clone()],
608        }
609    }
610}
611
612/// The typed result of a `strategy` command.
613#[derive(Debug, Clone, PartialEq, Eq)]
614pub enum StrategyReport {
615    /// `strategy list`: every declared facet.
616    Listed {
617        /// One descriptor per facet, in declaration order.
618        rows: Vec<StrategyFacetDescriptor>,
619    },
620    /// `strategy show`: one facet's descriptor plus its activation posture.
621    Shown {
622        /// The facet descriptor.
623        descriptor: StrategyFacetDescriptor,
624        /// The facet's activation + endpoint posture (`None` when no config
625        /// file could be read).
626        activation: Option<crate::strategy::EndpointSummary>,
627    },
628    /// `strategy activate`: the facet was activated with the settled posture.
629    Activated {
630        /// The activated facet.
631        facet: crate::strategy::StrategyFacet,
632        /// The settled endpoint posture.
633        summary: crate::strategy::EndpointSummary,
634        /// Whether the write applies or an env var shadows it.
635        outcome: crate::strategy::MutationOutcome,
636    },
637    /// `strategy deactivate`: the facet was deactivated.
638    Deactivated {
639        /// The deactivated facet.
640        facet: crate::strategy::StrategyFacet,
641        /// Whether the write applies or an env var shadows it.
642        outcome: crate::strategy::MutationOutcome,
643    },
644    /// `strategy set`: a facet key's override was written.
645    Set {
646        /// The mutated facet.
647        facet: crate::strategy::StrategyFacet,
648        /// The written key's field name.
649        key: &'static str,
650        /// Whether the write applies or an env var shadows it.
651        outcome: crate::strategy::MutationOutcome,
652    },
653    /// `strategy default` / `strategy remove`: the override was dropped so the
654    /// schema default applies again.
655    Defaulted {
656        /// The mutated facet.
657        facet: crate::strategy::StrategyFacet,
658        /// The restored key's field name.
659        key: &'static str,
660    },
661}
662
663impl StrategyReport {
664    /// The operator-facing lines for this report.
665    #[must_use]
666    pub fn render_lines(&self) -> Vec<String> {
667        match self {
668            Self::Listed { rows } => rows
669                .iter()
670                .map(|descriptor| {
671                    format!(
672                        "{} ({}) -> {} [{} declared key(s)]",
673                        descriptor.facet.as_str(),
674                        descriptor.trigger_kind,
675                        descriptor.config_section,
676                        descriptor.fields.len()
677                    )
678                })
679                .collect(),
680            Self::Shown {
681                descriptor,
682                activation,
683            } => {
684                let mut lines = vec![format!(
685                    "{}: {} strategy",
686                    descriptor.config_section, descriptor.trigger_kind
687                )];
688                lines.push(format!("  selector: {}", descriptor.facet.as_str()));
689                if let Some(summary) = activation {
690                    lines.push(format!("  posture: {}", posture_line(summary)));
691                }
692                if descriptor.fields.is_empty() {
693                    lines.push("  declared keys: (none yet)".to_string());
694                } else {
695                    for (field, env) in descriptor.fields.iter().zip(&descriptor.envs) {
696                        lines.push(format!("  {field} ({env})"));
697                    }
698                }
699                lines
700            }
701            Self::Activated {
702                facet,
703                summary,
704                outcome,
705            } => vec![
706                format!("activated strategy {}", facet.as_str()),
707                format!("  endpoints: {}", posture_line(summary)),
708                shadow_line(outcome),
709            ],
710            Self::Deactivated { facet, outcome } => vec![
711                format!(
712                    "deactivated strategy {} (recorded endpoints kept)",
713                    facet.as_str()
714                ),
715                shadow_line(outcome),
716            ],
717            Self::Set {
718                facet,
719                key,
720                outcome,
721            } => vec![
722                format!("set {}.{}", facet.config_section(), key),
723                shadow_line(outcome),
724            ],
725            Self::Defaulted { facet, key } => vec![format!(
726                "restored {}.{} to its declared default",
727                facet.config_section(),
728                key
729            )],
730        }
731    }
732}
733
734/// One line describing a facet's settled endpoint posture.
735fn posture_line(summary: &crate::strategy::EndpointSummary) -> String {
736    match summary {
737        crate::strategy::EndpointSummary::Inactive => "inactive".to_string(),
738        crate::strategy::EndpointSummary::Unset => {
739            "UNSET (activation requires --endpoints or --endpoints-default)".to_string()
740        }
741        crate::strategy::EndpointSummary::Default(urls) => {
742            format!("default ({})", urls.join(", "))
743        }
744        crate::strategy::EndpointSummary::Explicit(urls) => urls.join(", "),
745    }
746}
747
748/// The loud line when the env layer shadows a written key.
749fn shadow_line(outcome: &crate::strategy::MutationOutcome) -> String {
750    match outcome {
751        crate::strategy::MutationOutcome::Applied => "  applies at load time".to_string(),
752        crate::strategy::MutationOutcome::Shadowed { env } => {
753            format!("  WARNING: {env} is set in the environment and will shadow this write")
754        }
755    }
756}
757
758/// The green / red `pool verify` text.
759fn verification_lines(
760    pool: &str,
761    family: PoolFamily,
762    block_number: u64,
763    divergences: &[degenbot_pool_updater::LiquidityDivergence],
764) -> Vec<String> {
765    use degenbot_pool_updater::LiquidityDivergence;
766    if divergences.is_empty() {
767        return vec![format!(
768            "GREEN: {} pool {pool} matches on-chain truth at block {block_number}.",
769            family.as_str()
770        )];
771    }
772    let mut lines = vec![format!(
773        "RED: {} divergence(s) for {} pool {pool} at block {block_number}:",
774        divergences.len(),
775        family.as_str()
776    )];
777    for divergence in divergences {
778        lines.push(match divergence {
779            LiquidityDivergence::TickGross {
780                tick,
781                expected,
782                actual,
783            } => format!("  tick {tick}: TickGross expected={expected} actual={actual}"),
784            LiquidityDivergence::TickNet {
785                tick,
786                expected,
787                actual,
788            } => format!("  tick {tick}: TickNet expected={expected} actual={actual}"),
789            LiquidityDivergence::BitmapWord {
790                word,
791                expected,
792                actual,
793            } => format!("  word {word}: BitmapWord expected={expected} actual={actual}"),
794            LiquidityDivergence::TickPresence {
795                tick,
796                stored,
797                observed,
798            } => format!("  tick {tick}: TickPresence stored={stored} observed={observed}"),
799        });
800    }
801    lines
802}
803
804/// One `aave update` market's outcome.
805#[derive(Debug, Clone, PartialEq, Eq)]
806pub enum AaveUpdateOutcome {
807    /// The market advanced.
808    Advanced {
809        /// First block processed.
810        from_block: u64,
811        /// Last block advanced to.
812        to_block: u64,
813        /// Chunks committed.
814        chunks_committed: usize,
815        /// Events applied.
816        total_events_applied: usize,
817    },
818    /// The market's run was cooperatively cancelled.
819    Cancelled,
820    /// The market has no `last_update_block`; it is skipped (must be bootstrapped).
821    NeedsBootstrap,
822    /// `--dry-run`: the would-be advance, with nothing committed.
823    DryRun {
824        /// The market's `last_update_block`.
825        last_update_block: i64,
826        /// The resolved target (`None` = chain tip).
827        to_block: Option<u64>,
828    },
829}
830
831/// One `aave update` market row.
832#[derive(Debug, Clone, PartialEq, Eq)]
833pub struct AaveUpdateEntry {
834    /// The chain.
835    pub chain_id: i64,
836    /// The market id.
837    pub market_id: i64,
838    /// The market name.
839    pub market_name: String,
840    /// The outcome.
841    pub outcome: AaveUpdateOutcome,
842}
843
844/// One position row (scaled balance + token symbol).
845#[derive(Debug, Clone, PartialEq, Eq)]
846pub struct AavePositionLine {
847    /// The underlying token symbol (`Unknown` when unresolved).
848    pub symbol: String,
849    /// The scaled balance.
850    pub balance: U256,
851}
852
853/// The typed result of an `aave` command.
854#[derive(Debug, Clone, PartialEq, Eq)]
855pub enum AaveReport {
856    /// `aave activate`.
857    Activated {
858        /// The chain id.
859        chain_id: u64,
860        /// The human chain label.
861        chain_label: &'static str,
862        /// The market id.
863        market_id: i64,
864        /// The on-chain market name.
865        market_name: String,
866        /// Whether the market was newly created.
867        created: bool,
868    },
869    /// `aave deactivate`.
870    Deactivated {
871        /// The chain id.
872        chain_id: u64,
873        /// The market id, when a row was found.
874        market_id: Option<i64>,
875        /// The outcome.
876        outcome: DeactivateOutcome,
877    },
878    /// `aave update`.
879    Updated {
880        /// Per-market outcomes.
881        entries: Vec<AaveUpdateEntry>,
882    },
883    /// `aave position show`.
884    Position {
885        /// The user address (checksummed).
886        user_address: String,
887        /// The market name.
888        market: String,
889        /// The chain id.
890        chain_id: u64,
891        /// Collateral positions.
892        collateral: Vec<AavePositionLine>,
893        /// Debt positions.
894        debt: Vec<AavePositionLine>,
895    },
896    /// `aave position show` found no market.
897    PositionNoMarket {
898        /// The market name.
899        market: String,
900        /// The chain id.
901        chain_id: u64,
902    },
903    /// `aave position show` found no user row.
904    PositionNoUser {
905        /// The user address (checksummed).
906        user_address: String,
907        /// The market name.
908        market: String,
909        /// The chain id.
910        chain_id: u64,
911    },
912}
913
914impl AaveReport {
915    /// The operator-facing lines for this report.
916    #[must_use]
917    pub fn render_lines(&self) -> Vec<String> {
918        match self {
919            Self::Activated {
920                chain_id,
921                chain_label,
922                market_id,
923                market_name,
924                created,
925            } => vec![
926                format!("Activated Aave V3 on {chain_label} (chain ID {chain_id})."),
927                format!("  Market: {market_name} (id={market_id}, created={created})."),
928            ],
929            Self::Deactivated {
930                chain_id, outcome, ..
931            } => match outcome {
932                DeactivateOutcome::Deactivated => vec![format!(
933                    "Deactivated Aave V3 on {} (chain ID {chain_id}).",
934                    chain_label_for(*chain_id)
935                )],
936                DeactivateOutcome::AlreadyDeactivated => Vec::new(),
937                DeactivateOutcome::NoEntry => {
938                    vec![format!(
939                        "The database has no entry for Aave V3 on {} (chain ID {chain_id}).",
940                        chain_label_for(*chain_id)
941                    )]
942                }
943            },
944            Self::Updated { entries } => entries.iter().flat_map(entry_lines).collect(),
945            Self::Position {
946                user_address,
947                market,
948                chain_id,
949                collateral,
950                debt,
951            } => position_lines(user_address, market, *chain_id, collateral, debt),
952            Self::PositionNoMarket { market, chain_id } => {
953                vec![format!(
954                    "No market found with name '{market}' on chain {chain_id}."
955                )]
956            }
957            Self::PositionNoUser {
958                user_address,
959                market,
960                chain_id,
961            } => vec![format!(
962                "No Aave user found for address {user_address} in market '{market}' on chain \
963                 {chain_id}."
964            )],
965        }
966    }
967}
968
969/// The human chain label used in the aave report lines.
970fn chain_label_for(chain_id: u64) -> String {
971    match chain_id {
972        1 => "Ethereum".to_string(),
973        8453 => "Base".to_string(),
974        other => other.to_string(),
975    }
976}
977
978/// The lines for one `aave update` market.
979fn entry_lines(entry: &AaveUpdateEntry) -> Vec<String> {
980    let AaveUpdateEntry {
981        chain_id,
982        market_id,
983        market_name,
984        outcome,
985    } = entry;
986    match outcome {
987        AaveUpdateOutcome::Advanced {
988            from_block,
989            to_block,
990            chunks_committed,
991            total_events_applied,
992        } => vec![format!(
993            "Chain {chain_id} market {market_id} ({market_name}): advanced {from_block}-> \
994             {to_block} in {chunks_committed} chunks ({total_events_applied} events applied)."
995        )],
996        AaveUpdateOutcome::Cancelled => vec![format!(
997            "Chain {chain_id} market {market_id}: cancelled (committed chunks stay durable)."
998        )],
999        AaveUpdateOutcome::NeedsBootstrap => vec![format!(
1000            "Chain {chain_id} market {market_id} ({market_name}): needs bootstrapping \
1001             (last_update_block is None); skipping. Bootstrap the stamp before running."
1002        )],
1003        AaveUpdateOutcome::DryRun {
1004            last_update_block,
1005            to_block,
1006        } => vec![format!(
1007            "Dry run: would advance chain {chain_id} market {market_id} ({market_name}) from \
1008             block {last_update_block} to {} (no changes committed).",
1009            render_opt_block(*to_block)
1010        )],
1011    }
1012}
1013
1014/// Render an optional resolved block the way Python's `{value!r}` does.
1015fn render_opt_block(block: Option<u64>) -> String {
1016    block.map_or_else(|| "None".to_string(), |n| n.to_string())
1017}
1018
1019/// The ported `aave position show` body.
1020fn position_lines(
1021    user_address: &str,
1022    market: &str,
1023    chain_id: u64,
1024    collateral: &[AavePositionLine],
1025    debt: &[AavePositionLine],
1026) -> Vec<String> {
1027    let mut lines = vec![
1028        format!("Aave V3 Positions for {user_address}"),
1029        format!("Market: {market} (Chain: {chain_id})"),
1030        "=".repeat(60),
1031    ];
1032    if collateral.is_empty() {
1033        lines.push("No collateral positions found.".to_string());
1034    } else {
1035        lines.push("Collateral Positions:".to_string());
1036        lines.push("-".repeat(60));
1037        lines.extend(
1038            collateral
1039                .iter()
1040                .map(|p| format!("  {}: {} (scaled)", p.symbol, p.balance)),
1041        );
1042    }
1043    if debt.is_empty() {
1044        lines.push("No debt positions found.".to_string());
1045    } else {
1046        lines.push("Debt Positions:".to_string());
1047        lines.push("-".repeat(60));
1048        lines.extend(
1049            debt.iter()
1050                .map(|p| format!("  {}: {} (scaled)", p.symbol, p.balance)),
1051        );
1052    }
1053    lines
1054}
1055
1056/// The ported `heal` success / no-op text.
1057fn healed_lines(path: &std::path::Path, report: &HealReport) -> Vec<String> {
1058    if matches!(report.old_state, SchemaState::RustOwned { .. }) {
1059        return vec![format!(
1060            "Database at {} is already Rust-owned; heal is a no-op (no copy, no .bak).",
1061            path.display()
1062        )];
1063    }
1064    let total_rows: u64 = report.rows_copied.values().sum();
1065    let n_tables = report.rows_copied.len();
1066    let mut lines = vec![format!(
1067        "Healed database at {}: {total_rows} rows across {n_tables} tables copied; old DB \
1068         preserved at {}; new state: {}.",
1069        path.display(),
1070        report.bak_path.display(),
1071        schema_state_label(&report.new_state)
1072    )];
1073    if !report.warnings.is_empty() {
1074        lines.push(format!("Warnings: {}", report.warnings.join("; ")));
1075    }
1076    lines
1077}
1078
1079/// The ported `database cutover --dry-run` line for `state`.
1080#[must_use]
1081pub fn cutover_dry_run_line(state: &SchemaState) -> String {
1082    let label = schema_state_label(state);
1083    match state {
1084        SchemaState::LegacyAlembic => format!(
1085            "Schema state: {label}. Would cutover from Alembic to Rust ownership (drop \
1086             `alembic_version`, stamp `_degenbot_db_schema_version`)."
1087        ),
1088        SchemaState::RustOwned { .. } => {
1089            format!("Schema state: {label}. Already Rust-owned — cutover is a no-op.")
1090        }
1091        SchemaState::FreshStandalone { .. } => {
1092            format!("Schema state: {label}. No legacy history — nothing to cutover.")
1093        }
1094        SchemaState::Unrecognized => {
1095            format!("Schema state: {label}. Unrecognized database (foreign file).")
1096        }
1097    }
1098}
1099
1100/// The ported `database heal --dry-run` line for `state`.
1101#[must_use]
1102pub fn heal_dry_run_line(state: &SchemaState) -> String {
1103    let label = schema_state_label(state);
1104    match state {
1105        SchemaState::LegacyAlembic => format!(
1106            "Schema state: {label}. Would heal: rebuild at the Rust head schema, copy all \
1107             rows, drop alembic_version, stamp _degenbot_db_schema_version, atomic-swap with \
1108             a *.bak backup."
1109        ),
1110        SchemaState::RustOwned { .. } => {
1111            format!("Schema state: {label}. Already Rust-owned — heal is a no-op.")
1112        }
1113        SchemaState::FreshStandalone { .. } => format!(
1114            "Schema state: {label}. Empty file — heal produces a fresh RustOwned DB (0 rows \
1115             copied)."
1116        ),
1117        SchemaState::Unrecognized => {
1118            format!(
1119                "Schema state: {label}. Unrecognized database (foreign file) — heal would \
1120                 refuse."
1121            )
1122        }
1123    }
1124}
1125
1126/// The Python-compatible label for a [`SchemaState`] (mirrors the
1127/// `db_inspect_schema_state` return values).
1128#[must_use]
1129pub fn schema_state_label(state: &SchemaState) -> &'static str {
1130    match state {
1131        SchemaState::LegacyAlembic => "legacy_alembic",
1132        SchemaState::FreshStandalone { .. } => "fresh_standalone",
1133        SchemaState::RustOwned { .. } => "rust_owned",
1134        SchemaState::Unrecognized => "unrecognized",
1135    }
1136}