1use 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
22pub(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
49fn 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
75fn 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 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
99fn 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
108fn 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
147fn 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
179fn 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#[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
231fn 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}