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