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//! (ergo `6ATMVN`) 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    remove_file_if_exists(path)?;
86    ops::create_new_database(path)?;
87    tracing::info!(path = %path.display(), "initialized new SQLite database");
88    Ok(DatabaseReport::Reset {
89        path: path.to_path_buf(),
90    })
91}
92
93/// `compact`: `VACUUM` the database; never prompts.
94fn compact(path: &Path) -> Result<DatabaseReport, CliError> {
95    ops::compact_database(path)?;
96    tracing::info!(path = %path.display(), "compacted SQLite database");
97    Ok(DatabaseReport::Compacted {
98        path: path.to_path_buf(),
99    })
100}
101
102/// `cutover`: the opt-in one-way ownership flip (ADR-010). Refuses foreign /
103/// empty file DBs BEFORE any prompt; confirms unless `--force`.
104fn cutover(
105    path: &Path,
106    plan: PromptPlan,
107    dry_run: bool,
108    force: bool,
109    prompter: &dyn Prompter,
110) -> Result<DatabaseReport, CliError> {
111    let state = ops::inspect_schema_state(path)?;
112    if dry_run {
113        return Ok(DatabaseReport::DryRun {
114            path: path.to_path_buf(),
115            state,
116            kind: DryRunKind::Cutover,
117        });
118    }
119    match &state {
120        SchemaState::Unrecognized => return Err(CliError::DatabaseForeign),
121        SchemaState::FreshStandalone { .. } => return Err(CliError::DatabaseNothingToDo),
122        SchemaState::LegacyAlembic | SchemaState::RustOwned { .. } => {}
123    }
124    if plan.asks(force) && !prompter.confirm(&cutover_prompt(path), false) {
125        return Err(CliError::Aborted);
126    }
127    let outcome = if matches!(state, SchemaState::RustOwned { .. }) {
128        CutoverOutcome::AlreadyRustOwned
129    } else {
130        CutoverOutcome::Converted
131    };
132    ops::convert_alembic_to_rust_owned(path)?;
133    tracing::info!(path = %path.display(), "database schema ownership cutover");
134    Ok(DatabaseReport::Cutover {
135        path: path.to_path_buf(),
136        state,
137        outcome,
138    })
139}
140
141/// `heal`: the out-of-place dump-and-restore rebuild (ADR-011). ACCEPTS a
142/// legacy `alembic_version`-marked DB (unlike `cutover`); refuses a foreign
143/// file; confirms unless `--force`.
144fn heal(
145    path: &Path,
146    plan: PromptPlan,
147    dry_run: bool,
148    force: bool,
149    prompter: &dyn Prompter,
150) -> Result<DatabaseReport, CliError> {
151    let state = ops::inspect_schema_state(path)?;
152    if dry_run {
153        return Ok(DatabaseReport::DryRun {
154            path: path.to_path_buf(),
155            state,
156            kind: DryRunKind::Heal,
157        });
158    }
159    if matches!(state, SchemaState::Unrecognized) {
160        return Err(CliError::DatabaseForeign);
161    }
162    if plan.asks(force) && !prompter.confirm(&heal_prompt(path), false) {
163        return Err(CliError::Aborted);
164    }
165    let report = ops::heal_database(path)?;
166    tracing::info!(path = %path.display(), "healed SQLite database");
167    Ok(DatabaseReport::Healed {
168        path: path.to_path_buf(),
169        report,
170    })
171}
172
173/// `inspect`: read-only schema-state inspection; never writes.
174fn inspect(path: &Path) -> Result<DatabaseReport, CliError> {
175    let state = ops::inspect_schema_state(path)?;
176    Ok(DatabaseReport::Inspected {
177        path: path.to_path_buf(),
178        state,
179    })
180}
181
182/// The `.db.bak` sibling of `path`, mirroring Python's
183/// `Path.with_suffix(".db.bak")`.
184#[must_use]
185pub fn database_backup_path(path: &Path) -> PathBuf {
186    let mut backup = path.to_path_buf();
187    backup.set_extension("db.bak");
188    backup
189}
190
191fn backup_prompt(backup_path: &Path) -> String {
192    format!(
193        "An existing backup was found at {}. Do you want to replace it?",
194        backup_path.display()
195    )
196}
197
198fn reset_prompt(path: &Path) -> String {
199    format!(
200        "The existing database at {} will be removed and a new, empty database will be \
201         created and initialized using the schema included in degenbot version {}. Do you \
202         want to proceed?",
203        path.display(),
204        env!("CARGO_PKG_VERSION")
205    )
206}
207
208fn cutover_prompt(path: &Path) -> String {
209    format!(
210        "The database at {} will be cut over from Alembic to Rust schema ownership. This is \
211         ONE-WAY and cannot be undone. Proceed?",
212        path.display()
213    )
214}
215
216fn heal_prompt(path: &Path) -> String {
217    format!(
218        "The database at {} will be rebuilt out-of-place: a fresh Rust-schema DB is created, \
219         all user rows are copied across, and the result is atomically swapped into place \
220         (the old DB is preserved as *.bak). Proceed?",
221        path.display()
222    )
223}
224
225/// Remove `path` when present (Python's `unlink(missing_ok=True)`).
226fn remove_file_if_exists(path: &Path) -> Result<(), CliError> {
227    match std::fs::remove_file(path) {
228        Ok(()) => Ok(()),
229        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
230        Err(err) => Err(degenbot_db::DbError::Io(err).into()),
231    }
232}