1use anyhow::{Result, bail};
4use wowlab_common::{output, prompt};
5use wowlab_fs::{
6 directory::{self, EntryKind},
7 path::{Path, PathBuf},
8};
9use wowlab_types::{sensitive::Sensitive, table_registry::GameDataTable};
10
11use super::snapshot::{SyncArgs, SyncCredentials, db, run_sync_with_credentials};
12
13const DEFAULT_DATA_DIR: &str = "../wowlab-data";
14
15#[derive(Clone, Copy, Debug)]
16enum TableMode {
17 All,
18 Empty,
19 Select,
20}
21
22impl std::fmt::Display for TableMode {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 match self {
25 TableMode::All => write!(f, "All tables"),
26 TableMode::Empty => write!(f, "Only empty tables"),
27 TableMode::Select => write!(f, "Select tables"),
28 }
29 }
30}
31
32const TABLE_MODES: [TableMode; 3] = [TableMode::All, TableMode::Empty, TableMode::Select];
33
34struct MigrateConfig {
35 data_dir: PathBuf,
36 patch: String,
37 db_url: String,
38 supabase_url: String,
39 service_role_key: String,
40 tables: Vec<GameDataTable>,
41 json_only: bool,
42 dry_run: bool,
43}
44
45fn prompt_data_dir() -> Result<PathBuf> {
46 let data_dir = prompt::path("Data directory", Some(DEFAULT_DATA_DIR));
47
48 if directory::inspect(&data_dir)?.map(|info| info.kind()) != Some(EntryKind::Directory) {
49 bail!("Data directory does not exist: {}", data_dir.display());
50 }
51
52 Ok(directory::canonicalize(&data_dir)?)
53}
54
55fn prompt_patch(data_dir: &Path) -> String {
56 let detected = detect_patch(data_dir);
57
58 prompt::text("Patch version", detected.as_deref())
59}
60
61fn prompt_db_url() -> String {
62 let env_url = std::env::var("DATABASE_URL").ok();
63
64 let input = if let Some(ref existing) = env_url {
65 prompt::text("Database URL", Some(&mask_url(existing)))
66 } else {
67 prompt::password("Database URL")
68 };
69
70 if env_url.as_ref().is_some_and(|e| input == mask_url(e)) {
71 env_url.unwrap()
72 } else {
73 input
74 }
75}
76
77fn prompt_supabase_url() -> String {
78 let env_url = std::env::var("SUPABASE_URL").ok();
79
80 prompt::text("Supabase project URL", env_url.as_deref())
81}
82
83fn prompt_service_role_key() -> String {
84 let env_key = std::env::var("SUPABASE_SERVICE_ROLE_KEY").ok();
85
86 let input = if let Some(ref existing) = env_key {
87 prompt::text("Service role key", Some(&mask_key(existing)))
88 } else {
89 prompt::password("Service role key")
90 };
91
92 if env_key.as_ref().is_some_and(|e| input == mask_key(e)) {
93 env_key.unwrap()
94 } else {
95 input
96 }
97}
98
99fn mask_key(key: &str) -> String {
100 let prefix = key.chars().take(12).collect::<String>();
101
102 if prefix.chars().count() == key.chars().count() {
103 "***".to_string()
104 } else {
105 format!("{prefix}***")
106 }
107}
108
109async fn resolve_tables(mode: TableMode, database_url: &str) -> Result<Vec<GameDataTable>> {
110 match mode {
111 TableMode::All => Ok(Vec::new()),
112 TableMode::Empty => {
113 let pool = db::connect(database_url).await?;
114 let all = GameDataTable::iter().collect::<Vec<_>>();
115 let db_names: Vec<&str> = all.iter().map(|t| t.database_name()).collect();
116 let counts = db::count_rows(&pool, &db_names).await?;
117
118 let empty: Vec<GameDataTable> = all
119 .into_iter()
120 .filter(|t| {
121 let count = counts.get(t.database_name()).copied().unwrap_or(0);
122
123 count == 0
124 })
125 .collect();
126
127 if empty.is_empty() {
128 output::success("All tables already have data");
129 bail!("No empty tables to sync");
130 }
131
132 let names: Vec<_> = empty.iter().map(ToString::to_string).collect();
133
134 output::detail(&format!("Empty tables: {}", names.join(", ")));
135
136 Ok(empty)
137 }
138 TableMode::Select => {
139 let all = GameDataTable::iter().collect::<Vec<_>>();
140 let selected = prompt::multi_select_items("Select tables", &all);
141
142 if selected.is_empty() {
143 bail!("No tables selected");
144 }
145
146 Ok(selected)
147 }
148 }
149}
150
151async fn collect_inputs() -> Result<MigrateConfig> {
152 let data_dir = prompt_data_dir()?;
153 let patch = prompt_patch(&data_dir);
154 let db_url = prompt_db_url();
155 let supabase_url = prompt_supabase_url();
156 let service_role_key = prompt_service_role_key();
157
158 let mode_idx = prompt::select("Table mode", &TABLE_MODES);
159 let tables = resolve_tables(TABLE_MODES[mode_idx], &db_url).await?;
160
161 let json_only = prompt::confirm("Snapshot only (skip table writes)?", false);
162 let dry_run = prompt::confirm("Dry run?", false);
163
164 Ok(MigrateConfig {
165 data_dir,
166 patch,
167 db_url,
168 supabase_url,
169 service_role_key,
170 tables,
171 json_only,
172 dry_run,
173 })
174}
175
176fn confirm(config: &MigrateConfig) -> bool {
177 output::blank();
178 output::subheader("Summary");
179 prompt::resolved("Data dir", &config.data_dir.display().to_string());
180 prompt::resolved("Patch", &config.patch);
181 prompt::resolved("Database", &mask_url(&config.db_url));
182 prompt::resolved("Supabase", &config.supabase_url);
183
184 if config.tables.is_empty() {
185 prompt::resolved("Tables", "all");
186 } else {
187 let names: Vec<_> = config.tables.iter().map(ToString::to_string).collect();
188
189 prompt::resolved("Tables", &names.join(", "));
190 }
191
192 prompt::resolved("Snapshot only", if config.json_only { "yes" } else { "no" });
193 prompt::resolved("Dry run", if config.dry_run { "yes" } else { "no" });
194 output::blank();
195
196 prompt::confirm("Proceed?", true)
197}
198
199pub async fn run_migrate(quiet: bool) -> Result<()> {
204 if !quiet {
205 output::header("Interactive migration");
206 output::blank();
207 }
208
209 let config = collect_inputs().await?;
210
211 if !confirm(&config) {
212 output::warning("Aborted");
213
214 return Ok(());
215 }
216
217 output::blank();
218
219 let args = SyncArgs {
220 patch: config.patch,
221 data_dir: config.data_dir,
222 dry_run: config.dry_run,
223 json_only: config.json_only,
224 tables: config.tables,
225 };
226
227 let credentials = SyncCredentials {
228 database_url: Sensitive::new(config.db_url),
229 supabase_url: config.supabase_url,
230 service_role_key: Sensitive::new(config.service_role_key),
231 };
232
233 run_sync_with_credentials(args, &credentials).await
234}
235
236fn detect_patch(data_dir: &Path) -> Option<String> {
237 let changes_dir = data_dir.join("changes");
238 let file_names = directory::entries(&changes_dir)
239 .ok()?
240 .into_iter()
241 .filter_map(|entry| {
242 entry
243 .path()
244 .file_name()
245 .map(|name| name.to_string_lossy().into_owned())
246 });
247
248 latest_patch(file_names)
249}
250
251fn latest_patch(file_names: impl IntoIterator<Item = String>) -> Option<String> {
252 let mut versions = file_names
253 .into_iter()
254 .filter_map(|name| name.strip_suffix(".md").map(String::from))
255 .collect::<Vec<_>>();
256
257 versions.sort();
258
259 versions.pop()
260}
261
262fn mask_url(url: &str) -> String {
263 if let Some(at_pos) = url.find('@') {
264 if let Some(scheme_end) = url.find("://") {
265 return format!("{}://***{}", &url[..scheme_end], &url[at_pos..]);
266 }
267 }
268
269 "***".to_string()
270}
271
272#[cfg(test)]
273mod tests {
274 use googletest::prelude::*;
275 use wowlab_fs::{directory, file, temporary::Directory};
276
277 use super::{detect_patch, latest_patch, mask_key, mask_url};
278
279 #[gtest]
280 fn credential_masking_never_echoes_short_or_unstructured_secrets() -> Result<()> {
281 verify_that!(mask_key("short"), eq("***"))?;
282 verify_that!(mask_key("abcdefghijklmnop"), eq("abcdefghijkl***"))?;
283 verify_that!(
284 mask_url("postgresql://user:password@db.example/postgres"),
285 eq("postgresql://***@db.example/postgres")
286 )?;
287
288 verify_that!(mask_url("not-a-database-url"), eq("***"))
289 }
290
291 #[gtest]
292 fn patch_detection_uses_the_latest_sorted_change_file() -> Result<()> {
293 let files = ["11.2.0.md", "12.0.0.md", "notes.txt"].map(String::from);
294
295 verify_that!(latest_patch(files).as_deref(), some(eq("12.0.0")))
296 }
297
298 #[gtest]
299 fn patch_detection_reads_change_file_names_from_the_data_directory() -> Result<()> {
300 let data = Directory::new().or_fail()?;
301
302 verify_that!(detect_patch(data.path()), none())?;
303
304 let changes = data.path().join("changes");
305
306 directory::ensure(&changes).or_fail()?;
307 file::write_text(&changes.join("11.2.0.md"), "").or_fail()?;
308 file::write_text(&changes.join("12.0.0.md"), "").or_fail()?;
309 file::write_text(&changes.join("notes.txt"), "").or_fail()?;
310
311 verify_that!(detect_patch(data.path()).as_deref(), some(eq("12.0.0")))
312 }
313}