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 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
93fn 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
102fn 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
141fn 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
173fn 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#[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
225fn 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}