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 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
13/// Load all migration plans from the migrations directory, sorted by version.
14pub 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    // Sort by version number
37    plans.sort_by_key(|p| p.version);
38    Ok(plans)
39}
40
41/// Load migrations from a specific directory (for compile-time use in macros).
42pub fn load_migrations_from_dir(
43    project_root: Option<PathBuf>,
44) -> Result<Vec<MigrationPlan>, Box<dyn std::error::Error>> {
45    // Locate project root from CARGO_MANIFEST_DIR or use provided path
46    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    // Read vespertide.json or use defaults
55    let config = crate::config::load_config_or_default(Some(project_root.clone()))
56        .map_err(|e| format!("Failed to load config: {e}"))?;
57
58    // Read migrations directory
59    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    // Sort by version
84    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
164/// Load migrations at compile time (for macro use).
165pub 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    // A non-migration-extension file (e.g. `.txt`) must be ignored by the
238    // collector. Pins `path.is_file() && has_migration_extension(&path)`: a
239    // `&& -> ||` mutant would pick the `.txt` up, then fail to parse it as a
240    // migration plan and surface an error instead of an empty load.
241    #[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        // Note: Testing file read errors (line 85) is extremely difficult in unit tests
497        // because it requires actual I/O errors like:
498        // - Disk failures
499        // - Permission issues
500        // - File locks from other processes
501        // - Network filesystem issues
502        //
503        // The error handling code path at line 85 exists and will be executed
504        // in real-world scenarios when file read errors occur.
505        // The format! macro and error message construction are tested through
506        // other error paths (invalid JSON/YAML parsing).
507        //
508        // For now, we verify the function works correctly with valid files.
509        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        // Save the original value
524        let original = env::var("CARGO_MANIFEST_DIR").ok();
525
526        // Remove CARGO_MANIFEST_DIR to test the error path
527        // Note: remove_var is unsafe in multi-threaded environments,
528        // but serial_test ensures tests run sequentially
529        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        // Restore the original value if it existed
539        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        // This function just calls load_migrations_from_dir(None)
549        // We can't easily test it without CARGO_MANIFEST_DIR, but we can verify
550        // it doesn't panic
551        let result = load_migrations_at_compile_time();
552        // This might succeed if CARGO_MANIFEST_DIR is set (like in cargo test)
553        // or fail if it's not set
554        // Either way, we're testing the code path
555        let _ = result;
556    }
557}