vespertide_loader/
migrations.rs1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use rayon::prelude::*;
7use vespertide_config::VespertideConfig;
8use vespertide_core::MigrationPlan;
9use vespertide_planner::validate_migration_plan;
10
11use crate::parallel_config::{LOAD_FILES_PAR_MIN_LEN, LOAD_FILES_PAR_THRESHOLD};
12
13pub fn load_migrations(config: &VespertideConfig) -> Result<Vec<MigrationPlan>> {
15 let migrations_dir = config.migrations_dir();
16 if !migrations_dir.exists() {
17 return Ok(Vec::new());
18 }
19
20 let paths = collect_migration_paths(migrations_dir)?;
21 let results: Vec<Result<MigrationPlan>> = if paths.len() < LOAD_FILES_PAR_THRESHOLD {
22 paths.iter().map(|path| load_migration_file(path)).collect()
23 } else {
24 paths
25 .par_iter()
26 .with_min_len(LOAD_FILES_PAR_MIN_LEN)
27 .map(|path| load_migration_file(path))
28 .collect()
29 };
30
31 let mut plans = Vec::with_capacity(results.len());
32 for result in results {
33 plans.push(result?);
34 }
35
36 plans.sort_by_key(|p| p.version);
38 Ok(plans)
39}
40
41pub fn load_migrations_from_dir(
43 project_root: Option<PathBuf>,
44) -> Result<Vec<MigrationPlan>, Box<dyn std::error::Error>> {
45 let project_root = if let Some(root) = project_root {
47 root
48 } else {
49 let manifest_dir = env::var("CARGO_MANIFEST_DIR")
50 .map_err(|_| "CARGO_MANIFEST_DIR environment variable not set")?;
51 PathBuf::from(manifest_dir)
52 };
53
54 let config = crate::config::load_config_or_default(Some(project_root.clone()))
56 .map_err(|e| format!("Failed to load config: {e}"))?;
57
58 let migrations_dir = project_root.join(config.migrations_dir());
60 if !migrations_dir.exists() {
61 return Ok(Vec::new());
62 }
63
64 let paths = collect_migration_paths_internal(&migrations_dir)?;
65 let results: Vec<Result<MigrationPlan, String>> = if paths.len() < LOAD_FILES_PAR_THRESHOLD {
66 paths
67 .iter()
68 .map(|path| load_migration_file_internal(path))
69 .collect()
70 } else {
71 paths
72 .par_iter()
73 .with_min_len(LOAD_FILES_PAR_MIN_LEN)
74 .map(|path| load_migration_file_internal(path))
75 .collect()
76 };
77
78 let mut plans = Vec::with_capacity(results.len());
79 for result in results {
80 plans.push(result.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?);
81 }
82
83 plans.sort_by_key(|p| p.version);
85 Ok(plans)
86}
87
88fn collect_migration_paths(dir: &Path) -> Result<Vec<PathBuf>> {
89 let entries = fs::read_dir(dir).context("read migrations directory")?;
90 let mut paths = Vec::new();
91
92 for entry in entries {
93 let entry = entry.context("read directory entry")?;
94 let path = entry.path();
95 if path.is_file() && has_migration_extension(&path) {
96 paths.push(path);
97 }
98 }
99
100 Ok(paths)
101}
102
103fn collect_migration_paths_internal(dir: &Path) -> Result<Vec<PathBuf>, String> {
104 let entries =
105 fs::read_dir(dir).map_err(|e| format!("Failed to read migrations directory: {e}"))?;
106 let mut paths = Vec::new();
107
108 for entry in entries {
109 let entry = entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
110 let path = entry.path();
111 if path.is_file() && has_migration_extension(&path) {
112 paths.push(path);
113 }
114 }
115
116 Ok(paths)
117}
118
119fn has_migration_extension(path: &Path) -> bool {
120 matches!(
121 path.extension().and_then(|s| s.to_str()),
122 Some("json" | "yaml" | "yml")
123 )
124}
125
126fn load_migration_file(path: &Path) -> Result<MigrationPlan> {
127 let ext = path.extension().and_then(|s| s.to_str());
128 let content = fs::read_to_string(path)
129 .with_context(|| format!("read migration file: {}", path.display()))?;
130
131 let plan: MigrationPlan = if ext == Some("json") {
132 serde_json::from_str(&content)
133 .with_context(|| format!("parse migration: {}", path.display()))?
134 } else {
135 serde_yaml::from_str(&content)
136 .with_context(|| format!("parse migration: {}", path.display()))?
137 };
138
139 validate_migration_plan(&plan)
140 .with_context(|| format!("validate migration: {}", path.display()))?;
141
142 Ok(plan)
143}
144
145fn load_migration_file_internal(path: &Path) -> Result<MigrationPlan, String> {
146 let ext = path.extension().and_then(|s| s.to_str());
147 let content = fs::read_to_string(path)
148 .map_err(|e| format!("Failed to read migration file {}: {}", path.display(), e))?;
149
150 let plan: MigrationPlan = if ext == Some("json") {
151 serde_json::from_str(&content)
152 .map_err(|e| format!("Failed to parse JSON migration {}: {}", path.display(), e))?
153 } else {
154 serde_yaml::from_str(&content)
155 .map_err(|e| format!("Failed to parse YAML migration {}: {}", path.display(), e))?
156 };
157
158 validate_migration_plan(&plan)
159 .map_err(|e| format!("Failed to validate migration {}: {}", path.display(), e))?;
160
161 Ok(plan)
162}
163
164pub fn load_migrations_at_compile_time() -> Result<Vec<MigrationPlan>, Box<dyn std::error::Error>> {
166 load_migrations_from_dir(None)
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use serial_test::serial;
173 use std::env;
174 use std::fs;
175 use tempfile::TempDir;
176
177 struct CwdGuard {
178 original: PathBuf,
179 }
180
181 impl CwdGuard {
182 fn new(dir: &PathBuf) -> Self {
183 let original = env::current_dir().unwrap();
184 env::set_current_dir(dir).unwrap();
185 Self { original }
186 }
187 }
188
189 impl Drop for CwdGuard {
190 fn drop(&mut self) {
191 let _ = env::set_current_dir(&self.original);
192 }
193 }
194
195 fn write_config(dir: &std::path::Path) {
196 let cfg = VespertideConfig::default();
197 let text = serde_json::to_string_pretty(&cfg).unwrap();
198 fs::write(dir.join("vespertide.json"), text).unwrap();
199 }
200
201 #[test]
202 #[serial]
203 fn test_load_migrations_returns_empty_when_no_migrations_dir() {
204 let temp_dir = TempDir::new().unwrap();
205 let _guard = CwdGuard::new(&temp_dir.path().to_path_buf());
206 write_config(temp_dir.path());
207
208 let result = load_migrations(&VespertideConfig::default()).unwrap();
209 assert!(result.is_empty());
210 }
211
212 #[test]
213 #[serial]
214 fn test_load_migrations_reads_json_and_sorts_versions() {
215 let temp_dir = TempDir::new().unwrap();
216 let _guard = CwdGuard::new(&temp_dir.path().to_path_buf());
217 write_config(temp_dir.path());
218 fs::create_dir_all("migrations").unwrap();
219 fs::write(
220 "migrations/0002_second.json",
221 r#"{"version": 2, "actions": []}"#,
222 )
223 .unwrap();
224 fs::write(
225 "migrations/0001_first.json",
226 r#"{"version": 1, "actions": []}"#,
227 )
228 .unwrap();
229
230 let plans = load_migrations(&VespertideConfig::default()).unwrap();
231 assert_eq!(
232 plans.iter().map(|plan| plan.version).collect::<Vec<_>>(),
233 vec![1, 2]
234 );
235 }
236
237 #[test]
242 #[serial]
243 fn load_migrations_ignores_non_migration_extension_files() {
244 let temp_dir = TempDir::new().unwrap();
245 let _guard = CwdGuard::new(&temp_dir.path().to_path_buf());
246 write_config(temp_dir.path());
247 fs::create_dir_all("migrations").unwrap();
248 fs::write("migrations/notes.txt", "not a migration: {{{ invalid").unwrap();
249
250 let plans = load_migrations(&VespertideConfig::default()).unwrap();
251 assert_eq!(plans.len(), 0, "the .txt file must be skipped");
252 }
253
254 #[test]
255 fn load_migrations_from_dir_ignores_non_migration_extension_files() {
256 let temp_dir = TempDir::new().unwrap();
257 let migrations_dir = temp_dir.path().join("migrations");
258 fs::create_dir_all(&migrations_dir).unwrap();
259 fs::write(
260 migrations_dir.join("notes.txt"),
261 "not a migration: {{{ invalid",
262 )
263 .unwrap();
264
265 let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
266 assert!(
267 result.is_ok(),
268 "the .txt file must be skipped, not parsed: {result:?}"
269 );
270 assert_eq!(result.unwrap().len(), 0);
271 }
272
273 #[test]
274 fn test_load_migrations_from_dir_with_no_migrations_dir() {
275 let temp_dir = TempDir::new().unwrap();
276 let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
277 assert!(result.is_ok());
278 assert_eq!(result.unwrap().len(), 0);
279 }
280
281 #[test]
282 fn test_load_migrations_from_dir_with_empty_migrations_dir() {
283 let temp_dir = TempDir::new().unwrap();
284 let migrations_dir = temp_dir.path().join("migrations");
285 fs::create_dir_all(&migrations_dir).unwrap();
286
287 let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
288 assert!(result.is_ok());
289 assert_eq!(result.unwrap().len(), 0);
290 }
291
292 #[test]
293 fn test_load_migrations_from_dir_with_json_migration() {
294 let temp_dir = TempDir::new().unwrap();
295 let migrations_dir = temp_dir.path().join("migrations");
296 fs::create_dir_all(&migrations_dir).unwrap();
297
298 let migration_content = r#"{
299 "version": 1,
300 "actions": [
301 {
302 "type": "create_table",
303 "table": "users",
304 "columns": [
305 {
306 "name": "id",
307 "type": "integer",
308 "nullable": false
309 }
310 ],
311 "constraints": []
312 }
313 ]
314 }"#;
315
316 fs::write(migrations_dir.join("0001_test.json"), migration_content).unwrap();
317
318 let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
319 assert!(result.is_ok());
320 let plans = result.unwrap();
321 assert_eq!(plans.len(), 1);
322 assert_eq!(plans[0].version, 1);
323 }
324
325 #[test]
326 fn test_load_migrations_from_dir_sorts_by_version() {
327 let temp_dir = TempDir::new().unwrap();
328 let migrations_dir = temp_dir.path().join("migrations");
329 fs::create_dir_all(&migrations_dir).unwrap();
330
331 let migration1 = r#"{"version": 2, "actions": []}"#;
332 let migration2 = r#"{"version": 1, "actions": []}"#;
333 let migration3 = r#"{"version": 3, "actions": []}"#;
334
335 fs::write(migrations_dir.join("0002_second.json"), migration1).unwrap();
336 fs::write(migrations_dir.join("0001_first.json"), migration2).unwrap();
337 fs::write(migrations_dir.join("0003_third.json"), migration3).unwrap();
338
339 let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
340 assert!(result.is_ok());
341 let plans = result.unwrap();
342 assert_eq!(plans.len(), 3);
343 assert_eq!(plans[0].version, 1);
344 assert_eq!(plans[1].version, 2);
345 assert_eq!(plans[2].version, 3);
346 }
347
348 #[test]
349 fn test_load_migrations_from_dir_with_yaml_migration() {
350 let temp_dir = TempDir::new().unwrap();
351 let migrations_dir = temp_dir.path().join("migrations");
352 fs::create_dir_all(&migrations_dir).unwrap();
353
354 let migration_content = r"---
355version: 1
356actions:
357 - type: create_table
358 table: users
359 columns:
360 - name: id
361 type: integer
362 nullable: false
363 constraints: []
364";
365
366 fs::write(migrations_dir.join("0001_test.yaml"), migration_content).unwrap();
367
368 let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
369 assert!(result.is_ok());
370 let plans = result.unwrap();
371 assert_eq!(plans.len(), 1);
372 assert_eq!(plans[0].version, 1);
373 }
374
375 #[test]
376 fn test_load_migrations_from_dir_with_yml_migration() {
377 let temp_dir = TempDir::new().unwrap();
378 let migrations_dir = temp_dir.path().join("migrations");
379 fs::create_dir_all(&migrations_dir).unwrap();
380
381 let migration_content = r"---
382version: 1
383actions:
384 - type: create_table
385 table: users
386 columns:
387 - name: id
388 type: integer
389 nullable: false
390 constraints: []
391";
392
393 fs::write(migrations_dir.join("0001_test.yml"), migration_content).unwrap();
394
395 let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
396 assert!(result.is_ok());
397 let plans = result.unwrap();
398 assert_eq!(plans.len(), 1);
399 assert_eq!(plans[0].version, 1);
400 }
401
402 #[test]
403 #[serial]
404 fn test_load_migrations_reads_yaml_for_runtime_loader() {
405 let temp_dir = TempDir::new().unwrap();
406 let _guard = CwdGuard::new(&temp_dir.path().to_path_buf());
407 write_config(temp_dir.path());
408 fs::create_dir_all("migrations").unwrap();
409
410 let migration_content = r"---
411version: 1
412actions:
413 - type: create_table
414 table: users
415 columns:
416 - name: id
417 type: integer
418 nullable: false
419 constraints: []
420";
421 fs::write("migrations/0001_test.yaml", migration_content).unwrap();
422
423 let plans = load_migrations(&VespertideConfig::default()).unwrap();
424 assert_eq!(plans.len(), 1);
425 assert_eq!(plans[0].version, 1);
426 }
427
428 #[test]
429 fn test_load_migrations_from_dir_with_invalid_json() {
430 let temp_dir = TempDir::new().unwrap();
431 let migrations_dir = temp_dir.path().join("migrations");
432 fs::create_dir_all(&migrations_dir).unwrap();
433
434 let invalid_json = r#"{"version": 1, "actions": [invalid]}"#;
435 fs::write(migrations_dir.join("0001_invalid.json"), invalid_json).unwrap();
436
437 let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
438 assert!(result.is_err());
439 let err_msg = result.unwrap_err().to_string();
440 assert!(err_msg.contains("Failed to parse JSON migration"));
441 }
442
443 #[test]
444 fn test_load_migrations_from_dir_with_invalid_yaml() {
445 let temp_dir = TempDir::new().unwrap();
446 let migrations_dir = temp_dir.path().join("migrations");
447 fs::create_dir_all(&migrations_dir).unwrap();
448
449 let invalid_yaml = r"---
450version: 1
451actions:
452 - invalid: [syntax
453";
454 fs::write(migrations_dir.join("0001_invalid.yaml"), invalid_yaml).unwrap();
455
456 let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
457 assert!(result.is_err());
458 let err_msg = result.unwrap_err().to_string();
459 assert!(err_msg.contains("Failed to parse YAML migration"));
460 }
461
462 #[test]
463 fn test_load_migrations_from_dir_rejects_invalid_plan_with_file_path() {
464 let temp_dir = TempDir::new().unwrap();
465 let migrations_dir = temp_dir.path().join("migrations");
466 fs::create_dir_all(&migrations_dir).unwrap();
467
468 let migration_path = migrations_dir.join("0001_invalid_plan.json");
469 let invalid_plan = r#"{
470 "version": 1,
471 "actions": [
472 {
473 "type": "add_column",
474 "table": "nonexistent",
475 "column": {
476 "name": "required_value",
477 "type": "integer",
478 "nullable": false
479 }
480 }
481 ]
482 }"#;
483 fs::write(&migration_path, invalid_plan).unwrap();
484
485 let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
486 assert!(result.is_err());
487 let err_msg = result.unwrap_err().to_string();
488 assert!(
489 err_msg.contains(&migration_path.display().to_string()),
490 "error did not include file path {migration_path:?}: {err_msg}"
491 );
492 }
493
494 #[test]
495 fn test_load_migrations_from_dir_with_unreadable_file() {
496 let temp_dir = TempDir::new().unwrap();
510 let migrations_dir = temp_dir.path().join("migrations");
511 fs::create_dir_all(&migrations_dir).unwrap();
512
513 let file_path = migrations_dir.join("0001_test.json");
514 fs::write(&file_path, r#"{"version": 1, "actions": []}"#).unwrap();
515
516 let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
517 assert!(result.is_ok());
518 }
519
520 #[test]
521 #[serial]
522 fn test_load_migrations_from_dir_without_project_root() {
523 let original = env::var("CARGO_MANIFEST_DIR").ok();
525
526 unsafe {
530 env::remove_var("CARGO_MANIFEST_DIR");
531 }
532
533 let result = load_migrations_from_dir(None);
534 assert!(result.is_err());
535 let err_msg = result.unwrap_err().to_string();
536 assert!(err_msg.contains("CARGO_MANIFEST_DIR environment variable not set"));
537
538 if let Some(val) = original {
540 unsafe {
541 env::set_var("CARGO_MANIFEST_DIR", val);
542 }
543 }
544 }
545
546 #[test]
547 fn test_load_migrations_at_compile_time() {
548 let result = load_migrations_at_compile_time();
552 let _ = result;
556 }
557}