1use std::path::PathBuf;
5
6use alloy::primitives::U256;
7use degenbot_db::ops::HealReport;
8use degenbot_db::SchemaState;
9
10use crate::error::{CliError, ExitCode};
11use crate::pool::PoolFamily;
12use crate::strategy::StrategyFacetDescriptor;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum CommandReport {
17 Database(DatabaseReport),
19 Exchange(ExchangeReport),
21 Pool(PoolReport),
23 Aave(AaveReport),
25 Fleet(FleetReport),
27 Path(PathReport),
29 Strategy(StrategyReport),
31 Config(ConfigReport),
33}
34
35impl CommandReport {
36 #[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#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ConfigValue {
59 pub key: String,
61 pub value: String,
64 pub source: Option<degenbot_config::Source>,
66}
67
68impl ConfigValue {
69 #[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 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum ConfigReport {
121 Shown {
124 file: Option<PathBuf>,
126 values: Vec<ConfigValue>,
128 resolved: bool,
130 },
131 Path(PathBuf),
133 Got {
136 key: String,
138 value: String,
140 source: Option<degenbot_config::Source>,
142 },
143 Set {
145 key: String,
147 value: String,
149 outcome: crate::strategy::MutationOutcome,
151 },
152 Unset {
154 key: String,
156 outcome: crate::strategy::MutationOutcome,
158 },
159}
160
161impl ConfigReport {
162 #[must_use]
164 pub fn render_lines(&self) -> Vec<String> {
165 match self {
166 Self::Shown {
167 file,
168 values,
169 resolved,
170 } => {
171 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#[derive(Debug)]
212pub struct CommandOutcome {
213 result: Result<CommandReport, CliError>,
214 pub exit_code: ExitCode,
216}
217
218impl CommandOutcome {
219 #[must_use]
221 pub(crate) const fn new(result: Result<CommandReport, CliError>, exit_code: ExitCode) -> Self {
222 Self { result, exit_code }
223 }
224
225 #[must_use]
227 pub fn report(&self) -> Option<&CommandReport> {
228 self.result.as_ref().ok()
229 }
230
231 #[must_use]
233 pub fn error(&self) -> Option<&CliError> {
234 self.result.as_ref().err()
235 }
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum DryRunKind {
241 Cutover,
243 Heal,
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum CutoverOutcome {
250 Converted,
252 AlreadyRustOwned,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
258pub enum DatabaseReport {
259 BackedUp {
261 source: PathBuf,
263 backup: PathBuf,
265 },
266 Reset {
268 path: PathBuf,
270 },
271 Compacted {
273 path: PathBuf,
275 },
276 Inspected {
278 path: PathBuf,
280 state: SchemaState,
282 },
283 Cutover {
285 path: PathBuf,
287 state: SchemaState,
289 outcome: CutoverOutcome,
291 },
292 Healed {
294 path: PathBuf,
296 report: HealReport,
298 },
299 DryRun {
301 path: PathBuf,
303 state: SchemaState,
305 kind: DryRunKind,
307 },
308}
309
310impl DatabaseReport {
311 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub enum ActivateOutcome {
353 Activated,
355 AlreadyActive,
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum DeactivateOutcome {
362 Deactivated,
364 AlreadyDeactivated,
366 NoEntry,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum ExchangeActiveState {
373 Active,
375 Inactive,
377 NoEntry,
379}
380
381impl ExchangeActiveState {
382 #[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#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct ExchangeListRow {
396 pub chain_id: u64,
398 pub chain_label: &'static str,
400 pub display_name: &'static str,
402 pub dex_slug: &'static str,
404 pub factory: String,
406 pub state: ExchangeActiveState,
408}
409
410#[derive(Debug, Clone, PartialEq, Eq)]
412pub enum ExchangeReport {
413 Activated {
415 chain_id: u64,
417 chain_label: &'static str,
419 display_name: &'static str,
421 dex_slug: &'static str,
423 outcome: ActivateOutcome,
425 },
426 Deactivated {
428 chain_id: u64,
430 chain_label: &'static str,
432 display_name: &'static str,
434 dex_slug: &'static str,
436 outcome: DeactivateOutcome,
438 },
439 Listed {
441 rows: Vec<ExchangeListRow>,
443 },
444}
445
446impl ExchangeReport {
447 #[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#[derive(Debug, Clone, PartialEq, Eq)]
501pub enum PoolReport {
502 Updated {
504 chain_id: i64,
506 from_block: u64,
508 to_block: u64,
510 chunks_committed: usize,
512 total_pools_written: usize,
514 total_liquidity_applies: usize,
516 },
517 UpdateCancelled {
519 chain_id: i64,
521 },
522 Verified {
524 pool: String,
526 family: PoolFamily,
528 block_number: u64,
530 divergences: Vec<degenbot_pool_updater::LiquidityDivergence>,
532 },
533}
534
535impl PoolReport {
536 #[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#[derive(Debug, Clone, PartialEq, Eq)]
567pub enum FleetReport {
568 Posture {
572 effective: String,
574 },
575}
576
577impl FleetReport {
578 #[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#[derive(Debug, Clone, PartialEq, Eq)]
589pub enum PathReport {
590 Added {
592 detail: String,
594 },
595 Discovered {
597 detail: String,
599 },
600}
601
602impl PathReport {
603 #[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#[derive(Debug, Clone, PartialEq, Eq)]
614pub enum StrategyReport {
615 Listed {
617 rows: Vec<StrategyFacetDescriptor>,
619 },
620 Shown {
622 descriptor: StrategyFacetDescriptor,
624 activation: Option<crate::strategy::EndpointSummary>,
627 },
628 Activated {
630 facet: crate::strategy::StrategyFacet,
632 summary: crate::strategy::EndpointSummary,
634 outcome: crate::strategy::MutationOutcome,
636 },
637 Deactivated {
639 facet: crate::strategy::StrategyFacet,
641 outcome: crate::strategy::MutationOutcome,
643 },
644 Set {
646 facet: crate::strategy::StrategyFacet,
648 key: &'static str,
650 outcome: crate::strategy::MutationOutcome,
652 },
653 Defaulted {
656 facet: crate::strategy::StrategyFacet,
658 key: &'static str,
660 },
661}
662
663impl StrategyReport {
664 #[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
734fn 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
748fn 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
758fn 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#[derive(Debug, Clone, PartialEq, Eq)]
806pub enum AaveUpdateOutcome {
807 Advanced {
809 from_block: u64,
811 to_block: u64,
813 chunks_committed: usize,
815 total_events_applied: usize,
817 },
818 Cancelled,
820 NeedsBootstrap,
822 DryRun {
824 last_update_block: i64,
826 to_block: Option<u64>,
828 },
829}
830
831#[derive(Debug, Clone, PartialEq, Eq)]
833pub struct AaveUpdateEntry {
834 pub chain_id: i64,
836 pub market_id: i64,
838 pub market_name: String,
840 pub outcome: AaveUpdateOutcome,
842}
843
844#[derive(Debug, Clone, PartialEq, Eq)]
846pub struct AavePositionLine {
847 pub symbol: String,
849 pub balance: U256,
851}
852
853#[derive(Debug, Clone, PartialEq, Eq)]
855pub enum AaveReport {
856 Activated {
858 chain_id: u64,
860 chain_label: &'static str,
862 market_id: i64,
864 market_name: String,
866 created: bool,
868 },
869 Deactivated {
871 chain_id: u64,
873 market_id: Option<i64>,
875 outcome: DeactivateOutcome,
877 },
878 Updated {
880 entries: Vec<AaveUpdateEntry>,
882 },
883 Position {
885 user_address: String,
887 market: String,
889 chain_id: u64,
891 collateral: Vec<AavePositionLine>,
893 debt: Vec<AavePositionLine>,
895 },
896 PositionNoMarket {
898 market: String,
900 chain_id: u64,
902 },
903 PositionNoUser {
905 user_address: String,
907 market: String,
909 chain_id: u64,
911 },
912}
913
914impl AaveReport {
915 #[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
969fn 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
978fn 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
1014fn render_opt_block(block: Option<u64>) -> String {
1016 block.map_or_else(|| "None".to_string(), |n| n.to_string())
1017}
1018
1019fn 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
1056fn 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#[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#[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#[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}