Skip to main content

degenbot_cli_core/
database.rs

1//! The database command arms (ADR-051 D1/D4) — the template group.
2//!
3//! Ported verbatim from `src/degenbot/cli/database.py`: same dry-run text, same
4//! confirmation conditions, same refusals. `database upgrade` is retired — the
5//! database upgrades itself at open (ADR-052); `database heal` is the explicit
6//! repair.
7//!
8//! These arms never call heal on another command's behalf; the auto-heal epic
9//!  owns schema self-healing inside `ensure_schema`.
10
11use std::path::{Path, PathBuf};
12
13use degenbot_db::ops;
14use degenbot_db::SchemaState;
15
16use crate::command::DatabaseCommand;
17use crate::context::CliContext;
18use crate::error::CliError;
19use crate::prompt::{PromptPlan, Prompter};
20use crate::report::{CutoverOutcome, DatabaseReport, DryRunKind};
21
22/// Execute a `database` command.
23///
24/// # Errors
25///
26/// [`CliError`] for a declined prompt, a schema refusal
27/// ([`CliError::DatabaseForeign`] / [`CliError::DatabaseNothingToDo`]), the
28/// retired `upgrade` command, or a degenbot-db op failure.
29pub(crate) fn execute(
30    command: &DatabaseCommand,
31    ctx: &CliContext<'_>,
32    prompter: &dyn Prompter,
33) -> Result<DatabaseReport, CliError> {
34    let path = ctx.database_path().value;
35    let plan = command.prompt_plan(ctx);
36    match command {
37        DatabaseCommand::Backup => backup(&path, plan, prompter),
38        DatabaseCommand::Reset { force } => reset(&path, plan, *force, prompter),
39        DatabaseCommand::Upgrade { .. } => Err(CliError::DatabaseUpgradeRetired),
40        DatabaseCommand::Compact => compact(&path),
41        DatabaseCommand::Cutover { dry_run, force } => {
42            cutover(&path, plan, *dry_run, *force, prompter)
43        }
44        DatabaseCommand::Heal { dry_run, force } => heal(&path, plan, *dry_run, *force, prompter),
45        DatabaseCommand::Inspect => inspect(&path),
46    }
47}
48
49/// `backup`: write the `.db.bak` sibling, confirming replacement when the target
50/// already exists (the click handler's `BackupExists` policy).
51fn backup(
52    path: &Path,
53    plan: PromptPlan,
54    prompter: &dyn Prompter,
55) -> Result<DatabaseReport, CliError> {
56    let backup_path = database_backup_path(path);
57    if plan.asks(false) {
58        if !prompter.confirm(&backup_prompt(&backup_path), false) {
59            return Err(CliError::Aborted);
60        }
61        remove_file_if_exists(&backup_path)?;
62    }
63    ops::backup_database(path, &backup_path)?;
64    tracing::info!(
65        source = %path.display(),
66        backup = %backup_path.display(),
67        "backed up SQLite database"
68    );
69    Ok(DatabaseReport::BackedUp {
70        source: path.to_path_buf(),
71        backup: backup_path,
72    })
73}
74
75/// `reset`: remove and recreate the database (confirms unless `--force`).
76fn reset(
77    path: &Path,
78    plan: PromptPlan,
79    force: bool,
80    prompter: &dyn Prompter,
81) -> Result<DatabaseReport, CliError> {
82    if plan.asks(force) && !prompter.confirm(&reset_prompt(path), false) {
83        return Err(CliError::Aborted);
84    }
85    if let Some(parent) = path.parent() {
86        std::fs::create_dir_all(parent).map_err(CliError::Io)?;
87    }
88    remove_file_if_exists(path)?;
89    ops::create_new_database(path)?;
90    // A fresh DB is born fully registered: every supported (chain, DEX) pair
91    // + Aave market exists inactive, so activation is a pure flag flip.
92    crate::registrations::ensure_supported_registrations(path)?;
93    tracing::info!(path = %path.display(), "initialized new SQLite database");
94    Ok(DatabaseReport::Reset {
95        path: path.to_path_buf(),
96    })
97}
98
99/// `compact`: `VACUUM` the database; never prompts.
100fn compact(path: &Path) -> Result<DatabaseReport, CliError> {
101    ops::compact_database(path)?;
102    tracing::info!(path = %path.display(), "compacted SQLite database");
103    Ok(DatabaseReport::Compacted {
104        path: path.to_path_buf(),
105    })
106}
107
108/// `cutover`: the opt-in one-way ownership flip (ADR-010). Refuses foreign /
109/// empty file DBs BEFORE any prompt; confirms unless `--force`.
110fn cutover(
111    path: &Path,
112    plan: PromptPlan,
113    dry_run: bool,
114    force: bool,
115    prompter: &dyn Prompter,
116) -> Result<DatabaseReport, CliError> {
117    let state = ops::inspect_schema_state(path)?;
118    if dry_run {
119        return Ok(DatabaseReport::DryRun {
120            path: path.to_path_buf(),
121            state,
122            kind: DryRunKind::Cutover,
123        });
124    }
125    match &state {
126        SchemaState::Unrecognized => return Err(CliError::DatabaseForeign),
127        SchemaState::FreshStandalone { .. } => return Err(CliError::DatabaseNothingToDo),
128        SchemaState::LegacyAlembic | SchemaState::RustOwned { .. } => {}
129    }
130    if plan.asks(force) && !prompter.confirm(&cutover_prompt(path), false) {
131        return Err(CliError::Aborted);
132    }
133    let outcome = if matches!(state, SchemaState::RustOwned { .. }) {
134        CutoverOutcome::AlreadyRustOwned
135    } else {
136        CutoverOutcome::Converted
137    };
138    ops::convert_alembic_to_rust_owned(path)?;
139    tracing::info!(path = %path.display(), "database schema ownership cutover");
140    Ok(DatabaseReport::Cutover {
141        path: path.to_path_buf(),
142        state,
143        outcome,
144    })
145}
146
147/// `heal`: the out-of-place dump-and-restore rebuild (ADR-011). ACCEPTS a
148/// legacy `alembic_version`-marked DB (unlike `cutover`); refuses a foreign
149/// file; confirms unless `--force`.
150fn heal(
151    path: &Path,
152    plan: PromptPlan,
153    dry_run: bool,
154    force: bool,
155    prompter: &dyn Prompter,
156) -> Result<DatabaseReport, CliError> {
157    let state = ops::inspect_schema_state(path)?;
158    if dry_run {
159        return Ok(DatabaseReport::DryRun {
160            path: path.to_path_buf(),
161            state,
162            kind: DryRunKind::Heal,
163        });
164    }
165    if matches!(state, SchemaState::Unrecognized) {
166        return Err(CliError::DatabaseForeign);
167    }
168    if plan.asks(force) && !prompter.confirm(&heal_prompt(path), false) {
169        return Err(CliError::Aborted);
170    }
171    let report = ops::heal_database(path)?;
172    tracing::info!(path = %path.display(), "healed SQLite database");
173    Ok(DatabaseReport::Healed {
174        path: path.to_path_buf(),
175        report,
176    })
177}
178
179/// `inspect`: read-only schema-state inspection; never writes.
180fn inspect(path: &Path) -> Result<DatabaseReport, CliError> {
181    let state = ops::inspect_schema_state(path)?;
182    Ok(DatabaseReport::Inspected {
183        path: path.to_path_buf(),
184        state,
185    })
186}
187
188/// The `.db.bak` sibling of `path`, mirroring Python's
189/// `Path.with_suffix(".db.bak")`.
190#[must_use]
191pub fn database_backup_path(path: &Path) -> PathBuf {
192    let mut backup = path.to_path_buf();
193    backup.set_extension("db.bak");
194    backup
195}
196
197fn backup_prompt(backup_path: &Path) -> String {
198    format!(
199        "An existing backup was found at {}. Do you want to replace it?",
200        backup_path.display()
201    )
202}
203
204fn reset_prompt(path: &Path) -> String {
205    format!(
206        "The existing database at {} will be removed and a new, empty database will be \
207         created and initialized using the schema included in degenbot version {}. Do you \
208         want to proceed?",
209        path.display(),
210        env!("CARGO_PKG_VERSION")
211    )
212}
213
214fn cutover_prompt(path: &Path) -> String {
215    format!(
216        "The database at {} will be cut over from Alembic to Rust schema ownership. This is \
217         ONE-WAY and cannot be undone. Proceed?",
218        path.display()
219    )
220}
221
222fn heal_prompt(path: &Path) -> String {
223    format!(
224        "The database at {} will be rebuilt out-of-place: a fresh Rust-schema DB is created, \
225         all user rows are copied across, and the result is atomically swapped into place \
226         (the old DB is preserved as *.bak). Proceed?",
227        path.display()
228    )
229}
230
231/// Remove `path` when present (Python's `unlink(missing_ok=True)`).
232fn remove_file_if_exists(path: &Path) -> Result<(), CliError> {
233    match std::fs::remove_file(path) {
234        Ok(()) => Ok(()),
235        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
236        Err(err) => Err(degenbot_db::DbError::Io(err).into()),
237    }
238}