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