1use std::collections::HashSet;
2use std::ffi::OsStr;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail, ensure};
6use codegen::{Function, Scope};
7use fs_err as fs;
8
9use super::Migrator;
10
11pub const GENERATED_MIGRATOR_FILE: &str = "db_migrator.rs";
12
13impl Migrator {
14 pub fn generate(migration_dir: impl AsRef<Path>) -> Result<PathBuf> {
77 let migration_dir = migration_dir_path(migration_dir.as_ref());
78 build_rs::output::rerun_if_changed(&migration_dir);
79
80 let out_path = build_rs::input::out_dir().join(GENERATED_MIGRATOR_FILE);
81 let migrations = discover_migrations(&migration_dir)?;
82 fs::write(
83 &out_path,
84 render_migrator(&migrations.retired_migrations, &migrations.active_migrations)?,
85 )
86 .with_context(|| format!("failed to write generated migrator to {}", out_path.display()))?;
87 Ok(out_path)
88 }
89}
90
91fn migration_dir_path(migration_dir: &Path) -> PathBuf {
92 if migration_dir.is_absolute() {
93 migration_dir.to_path_buf()
94 } else {
95 build_rs::input::cargo_manifest_dir().join(migration_dir)
96 }
97}
98
99#[derive(Debug)]
100struct DiscoveredMigrations {
101 retired_migrations: Vec<SqlMigration>,
102 active_migrations: Vec<ActiveMigration>,
103}
104
105#[derive(Debug)]
106struct SqlMigration {
107 name: String,
108 path: PathBuf,
109}
110
111#[derive(Debug)]
112struct CodeMigration {
113 name: String,
114 module_ident: String,
115 path: PathBuf,
116}
117
118#[derive(Debug)]
119enum ActiveMigration {
120 Sql(SqlMigration),
121 Code(CodeMigration),
122}
123
124fn discover_migrations(migration_dir: &Path) -> Result<DiscoveredMigrations> {
125 ensure!(
126 migration_dir.is_dir(),
127 "migration path is not a directory: {}",
128 migration_dir.display()
129 );
130
131 let retired_migrations = discover_retired_migrations(migration_dir)?;
132 let active_migrations = discover_active_migrations(migration_dir)?;
133 ensure!(
134 !retired_migrations.is_empty() || !active_migrations.is_empty(),
135 "migration directory contains no migrations: {}",
136 migration_dir.display()
137 );
138
139 Ok(DiscoveredMigrations { retired_migrations, active_migrations })
140}
141
142fn discover_retired_migrations(migration_dir: &Path) -> Result<Vec<SqlMigration>> {
143 let retired_dir = migration_dir.join("retired");
144 if !retired_dir.exists() {
145 return Ok(Vec::new());
146 }
147
148 ensure!(
149 retired_dir.is_dir(),
150 "retired migration path is not a directory: {}",
151 retired_dir.display()
152 );
153
154 let mut seen_prefixes = HashSet::new();
155 let mut migrations = Vec::new();
156 for entry in read_dir_sorted(&retired_dir)? {
157 let path = entry.path();
158 ensure!(path.is_file(), "retired migration entry is not a file: {}", path.display());
159 ensure!(
160 path.extension() == Some(OsStr::new("sql")),
161 "retired migration file must use .sql extension: {}",
162 path.display()
163 );
164
165 let name = file_stem(&path)?;
166 let prefix = migration_prefix(&name, &path)?;
167 ensure!(
168 seen_prefixes.insert(prefix.to_owned()),
169 "duplicate retired migration prefix {prefix:?}"
170 );
171
172 migrations.push(SqlMigration { name, path: absolute_path(&path)? });
173 }
174
175 Ok(migrations)
176}
177
178fn discover_active_migrations(migration_dir: &Path) -> Result<Vec<ActiveMigration>> {
179 let mut seen_prefixes = HashSet::new();
180 let mut migrations = Vec::new();
181 for entry in read_dir_sorted(migration_dir)? {
182 let path = entry.path();
183 if path.is_dir() {
184 continue;
185 }
186
187 ensure!(path.is_file(), "active migration entry is not a file: {}", path.display());
188
189 let name = file_stem(&path)?;
190 let prefix = migration_prefix(&name, &path)?;
191 ensure!(
192 seen_prefixes.insert(prefix.to_owned()),
193 "duplicate active migration prefix {prefix:?}"
194 );
195
196 match path.extension().and_then(OsStr::to_str) {
197 Some("sql") => {
198 migrations
199 .push(ActiveMigration::Sql(SqlMigration { name, path: absolute_path(&path)? }));
200 },
201 Some("rs") => {
202 let module_ident = module_ident(&name)?;
203
204 migrations.push(ActiveMigration::Code(CodeMigration {
205 name,
206 module_ident,
207 path: absolute_path(&path)?,
208 }));
209 },
210 _ => {
211 bail!("active migration file must use .sql or .rs extension: {}", path.display());
212 },
213 }
214 }
215
216 Ok(migrations)
217}
218
219fn render_migrator(
238 retired_migrations: &[SqlMigration],
239 active_migrations: &[ActiveMigration],
240) -> Result<String> {
241 let mut scope = Scope::new();
242
243 for migration in active_migrations {
244 let ActiveMigration::Code(migration) = migration else {
245 continue;
246 };
247
248 let path = format!("{:?}", rust_path(&migration.path)?);
249 scope.raw(format!("#[path = {path}]\nmod {};", migration.module_ident));
250 }
251
252 let mut function = Function::new("migrator");
253 function.vis("pub");
254 function.ret("::anyhow::Result<::miden_node_db::migration::Migrator>");
255 function.line("::miden_node_db::migration::Migrator::builder()?");
256
257 for migration in retired_migrations {
258 let name = format!("{:?}", migration.name);
259 let path = format!("{:?}", rust_path(&migration.path)?);
260 function.line(format!(" .push_retired({name}, include_str!({path}))?"));
261 }
262
263 for migration in active_migrations {
264 match migration {
265 ActiveMigration::Sql(migration) => {
266 let name = format!("{:?}", migration.name);
267 let path = format!("{:?}", rust_path(&migration.path)?);
268 function.line(format!(" .push_sql({name}, include_str!({path}))?"));
269 },
270 ActiveMigration::Code(migration) => {
271 let name = format!("{:?}", migration.name);
272 function
273 .line(format!(" .push_code({name}, {}::migrate)?", migration.module_ident));
274 },
275 }
276 }
277
278 function.line(" .build()");
279 scope.push_fn(function);
280
281 let mut source = scope.to_string();
282 source.push('\n');
283 Ok(source)
284}
285
286fn read_dir_sorted(dir: &Path) -> Result<Vec<fs::DirEntry>> {
287 let mut entries = fs::read_dir(dir)
288 .with_context(|| format!("failed to read migration directory {}", dir.display()))?
289 .collect::<std::result::Result<Vec<_>, _>>()
290 .with_context(|| {
291 format!("failed to read migration directory entry in {}", dir.display())
292 })?;
293 entries.sort_by_key(fs::DirEntry::file_name);
294 Ok(entries)
295}
296
297fn absolute_path(path: &Path) -> Result<PathBuf> {
298 fs::canonicalize(path)
299 .with_context(|| format!("failed to canonicalize migration path {}", path.display()))
300}
301
302fn file_stem(path: &Path) -> Result<String> {
303 path.file_stem().and_then(OsStr::to_str).map(str::to_owned).with_context(|| {
304 format!("migration file has invalid UTF-8 stem or no stem: {}", path.display())
305 })
306}
307
308fn migration_prefix<'a>(name: &'a str, path: &Path) -> Result<&'a str> {
309 let bytes = name.as_bytes();
310 ensure!(
311 bytes.len() > 4
312 && bytes[0].is_ascii_digit()
313 && bytes[1].is_ascii_digit()
314 && bytes[2].is_ascii_digit()
315 && bytes[3] == b'_'
316 && name[4..].chars().any(|ch| ch.is_ascii_alphanumeric()),
317 "migration file name must start with a three-digit prefix followed by an underscore, e.g. \
318 001_initial: {}",
319 path.display()
320 );
321
322 ensure!(
323 &name[..3] != "000",
324 "migration file prefix must start at 001: {}",
325 path.display()
326 );
327
328 Ok(&name[..3])
329}
330
331fn module_ident(name: &str) -> Result<String> {
337 ensure!(
338 name.chars().any(|ch| ch.is_ascii_alphanumeric()),
339 "migration name {name:?} cannot be converted to a Rust module identifier"
340 );
341
342 let ident = name
343 .chars()
344 .map(|ch| {
345 if ch.is_ascii_alphanumeric() {
346 ch.to_ascii_lowercase()
347 } else {
348 '_'
349 }
350 })
351 .collect::<String>();
352
353 Ok(format!("migration_{ident}"))
354}
355
356fn rust_path(path: &Path) -> Result<&str> {
357 path.to_str()
358 .with_context(|| format!("migration path is not valid UTF-8: {}", path.display()))
359}
360
361#[cfg(test)]
362mod tests {
363 use std::env;
364
365 use super::*;
366
367 #[test]
368 fn renders_migrations_in_lexicographic_order() -> Result<()> {
369 let root = unique_temp_dir("renders_migrations_in_lexicographic_order")?;
370 fs::create_dir_all(root.join("retired"))?;
371 fs::create_dir_all(root.join("003_backfill"))?;
372 fs::write(root.join("retired").join("001_legacy.sql"), "CREATE TABLE t (id INTEGER);")?;
373 fs::write(root.join("002_indexes.sql"), "CREATE INDEX idx ON t(id);")?;
374 fs::write(
375 root.join("003_backfill.rs"),
376 "pub fn migrate(_: &rusqlite::Transaction<'_>) -> anyhow::Result<()> { Ok(()) }",
377 )?;
378 fs::write(root.join("003_backfill").join("fixture.bin"), "supporting data")?;
379
380 let retired = discover_retired_migrations(&root)?;
381 let active = discover_active_migrations(&root)?;
382 let rendered = render_migrator(&retired, &active)?;
383
384 let legacy = rendered.find("\"001_legacy\"").expect("legacy migration is rendered");
385 let indexes = rendered.find("\"002_indexes\"").expect("index migration is rendered");
386 let backfill = rendered.find("\"003_backfill\"").expect("code migration is rendered");
387
388 assert!(legacy < indexes);
389 assert!(indexes < backfill);
390 assert!(rendered.contains("include_str!("));
391 assert!(rendered.contains(".push_retired("));
392 assert!(rendered.contains(".push_sql("));
393 assert!(rendered.contains(".push_code("));
394 assert!(!rendered.contains(".push_base("));
395 assert!(rendered.contains("migration_003_backfill::migrate"));
396 assert!(rendered.contains(".build()\n}\n"));
397 assert!(!rendered.contains("Ok(migrator)"));
398
399 fs::remove_dir_all(root)?;
400 Ok(())
401 }
402
403 #[test]
404 fn rejects_empty_migration_directory() -> Result<()> {
405 let root = unique_temp_dir("rejects_empty_migration_directory")?;
406
407 let err = discover_migrations(&root).expect_err("empty migration directory should fail");
408
409 assert!(err.to_string().contains("contains no migrations"));
410 fs::remove_dir_all(root)?;
411 Ok(())
412 }
413
414 #[test]
415 fn rejects_invalid_retired_migration_entries() -> Result<()> {
416 let root = unique_temp_dir("rejects_invalid_retired_migration_entries")?;
417 fs::create_dir_all(root.join("retired"))?;
418 fs::write(root.join("retired").join("001_init.txt"), "CREATE TABLE t (id INTEGER);")?;
419
420 let err =
421 discover_retired_migrations(&root).expect_err("invalid retired entry should fail");
422
423 assert!(err.to_string().contains("must use .sql extension"));
424 fs::remove_dir_all(root)?;
425 Ok(())
426 }
427
428 #[test]
429 fn rejects_invalid_active_migration_file_extension() -> Result<()> {
430 let root = unique_temp_dir("rejects_invalid_active_migration_file_extension")?;
431 fs::write(root.join("001_init.txt"), "CREATE TABLE t (id INTEGER);")?;
432
433 let err = discover_active_migrations(&root).expect_err("invalid entry should fail");
434
435 assert!(err.to_string().contains("must use .sql or .rs extension"));
436 fs::remove_dir_all(root)?;
437 Ok(())
438 }
439
440 #[test]
441 fn rejects_active_migrations_without_three_digit_prefix() -> Result<()> {
442 let root = unique_temp_dir("rejects_active_migrations_without_three_digit_prefix")?;
443 fs::write(root.join("1_init.sql"), "CREATE TABLE t (id INTEGER);")?;
444
445 let err = discover_active_migrations(&root).expect_err("invalid prefix should fail");
446
447 assert!(err.to_string().contains("three-digit prefix"));
448 fs::remove_dir_all(root)?;
449 Ok(())
450 }
451
452 #[test]
453 fn rejects_retired_migrations_without_three_digit_prefix() -> Result<()> {
454 let root = unique_temp_dir("rejects_retired_migrations_without_three_digit_prefix")?;
455 fs::create_dir_all(root.join("retired"))?;
456 fs::write(root.join("retired").join("init.sql"), "CREATE TABLE t (id INTEGER);")?;
457
458 let err = discover_retired_migrations(&root).expect_err("invalid prefix should fail");
459
460 assert!(err.to_string().contains("three-digit prefix"));
461 fs::remove_dir_all(root)?;
462 Ok(())
463 }
464
465 #[test]
466 fn rejects_duplicate_active_migration_prefixes() -> Result<()> {
467 let root = unique_temp_dir("rejects_duplicate_active_migration_prefixes")?;
468 fs::write(root.join("001_init.sql"), "CREATE TABLE t (id INTEGER);")?;
469 fs::write(root.join("001_indexes.sql"), "CREATE INDEX idx ON t(id);")?;
470
471 let err = discover_active_migrations(&root).expect_err("duplicate prefix should fail");
472
473 assert!(err.to_string().contains("duplicate active migration prefix"));
474 fs::remove_dir_all(root)?;
475 Ok(())
476 }
477
478 #[test]
479 fn rejects_duplicate_retired_migration_prefixes() -> Result<()> {
480 let root = unique_temp_dir("rejects_duplicate_retired_migration_prefixes")?;
481 fs::create_dir_all(root.join("retired"))?;
482 fs::write(root.join("retired").join("001_init.sql"), "CREATE TABLE t (id INTEGER);")?;
483 fs::write(root.join("retired").join("001_indexes.sql"), "CREATE INDEX idx ON t(id);")?;
484
485 let err = discover_retired_migrations(&root).expect_err("duplicate prefix should fail");
486
487 assert!(err.to_string().contains("duplicate retired migration prefix"));
488 fs::remove_dir_all(root)?;
489 Ok(())
490 }
491
492 #[test]
493 fn rejects_zero_migration_prefix() -> Result<()> {
494 let root = unique_temp_dir("rejects_zero_migration_prefix")?;
495 fs::write(root.join("000_init.sql"), "CREATE TABLE t (id INTEGER);")?;
496
497 let err = discover_active_migrations(&root).expect_err("zero prefix should fail");
498
499 assert!(err.to_string().contains("prefix must start at 001"));
500 fs::remove_dir_all(root)?;
501 Ok(())
502 }
503
504 #[test]
505 fn module_ident_preserves_repeated_separators() -> Result<()> {
506 assert_eq!(module_ident("001--backfill")?, "migration_001__backfill");
507 Ok(())
508 }
509
510 #[test]
511 fn migration_dir_path_resolves_relative_paths_from_manifest_dir() {
512 assert_eq!(
513 migration_dir_path(Path::new("migrations")),
514 build_rs::input::cargo_manifest_dir().join("migrations")
515 );
516
517 let absolute = env::temp_dir().join("miden-node-db-absolute-migrations");
518 assert_eq!(migration_dir_path(&absolute), absolute);
519 }
520
521 fn unique_temp_dir(name: &str) -> Result<PathBuf> {
522 let dir = env::temp_dir().join(format!("miden-node-db-{name}-{}", std::process::id()));
523 if dir.exists() {
524 fs::remove_dir_all(&dir)?;
525 }
526 fs::create_dir_all(&dir)?;
527 Ok(dir)
528 }
529}