Skip to main content

vespertide_loader/
migrations.rs

1use 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
12/// Load all migration plans from the migrations directory, sorted by version.
13pub 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    // Sort by version number
28    plans.sort_by_key(|p| p.version);
29    Ok(plans)
30}
31
32/// Load migrations from a specific directory (for compile-time use in macros).
33pub fn load_migrations_from_dir(
34    project_root: Option<PathBuf>,
35) -> Result<Vec<MigrationPlan>, Box<dyn std::error::Error>> {
36    // Locate project root from CARGO_MANIFEST_DIR or use provided path
37    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    // Read vespertide.json or use defaults
46    let config = crate::config::load_config_or_default(Some(project_root.clone()))
47        .map_err(|e| format!("Failed to load config: {e}"))?;
48
49    // Read migrations directory
50    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    // Sort by version
64    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
121/// Load migrations at compile time (for macro use).
122pub 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    // A non-migration-extension file (e.g. `.txt`) must be ignored by the
172    // collector. Pins `path.is_file() && has_migration_extension(&path)`: a
173    // `&& -> ||` mutant would pick the `.txt` up, then fail to parse it as a
174    // migration plan and surface an error instead of an empty load.
175    #[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        // Note: Testing file read errors (line 85) is extremely difficult in unit tests
431        // because it requires actual I/O errors like:
432        // - Disk failures
433        // - Permission issues
434        // - File locks from other processes
435        // - Network filesystem issues
436        //
437        // The error handling code path at line 85 exists and will be executed
438        // in real-world scenarios when file read errors occur.
439        // The format! macro and error message construction are tested through
440        // other error paths (invalid JSON/YAML parsing).
441        //
442        // For now, we verify the function works correctly with valid files.
443        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        // Save the original value
458        let original = env::var("CARGO_MANIFEST_DIR").ok();
459
460        // Remove CARGO_MANIFEST_DIR to test the error path
461        // Note: remove_var is unsafe in multi-threaded environments,
462        // but serial_test ensures tests run sequentially
463        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        // Restore the original value if it existed
473        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        // This function just calls load_migrations_from_dir(None)
483        // We can't easily test it without CARGO_MANIFEST_DIR, but we can verify
484        // it doesn't panic
485        let result = load_migrations_at_compile_time();
486        // This might succeed if CARGO_MANIFEST_DIR is set (like in cargo test)
487        // or fail if it's not set
488        // Either way, we're testing the code path
489        let _ = result;
490    }
491}