Skip to main content

wowlab_cli/commands/
migrate.rs

1// #t(file: rust_unwrap_in_lib) binary entrypoint, not library code
2
3use 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
199/// Runs the interactive migration workflow.
200///
201/// # Errors
202/// Returns an error if input collection or the migration sync fails.
203pub 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}