vespertide_loader/
migrations.rs

1use std::env;
2use std::fs;
3use std::path::PathBuf;
4
5use anyhow::{Context, Result};
6use vespertide_config::VespertideConfig;
7use vespertide_core::MigrationPlan;
8use vespertide_planner::validate_migration_plan;
9
10/// Load all migration plans from the migrations directory, sorted by version.
11pub fn load_migrations(config: &VespertideConfig) -> Result<Vec<MigrationPlan>> {
12    let migrations_dir = config.migrations_dir();
13    if !migrations_dir.exists() {
14        return Ok(Vec::new());
15    }
16
17    let mut plans = Vec::new();
18    let entries = fs::read_dir(migrations_dir).context("read migrations directory")?;
19
20    for entry in entries {
21        let entry = entry.context("read directory entry")?;
22        let path = entry.path();
23        if path.is_file() {
24            let ext = path.extension().and_then(|s| s.to_str());
25            if ext == Some("json") || ext == Some("yaml") || ext == Some("yml") {
26                let content = fs::read_to_string(&path)
27                    .with_context(|| format!("read migration file: {}", path.display()))?;
28
29                let plan: MigrationPlan = if ext == Some("json") {
30                    serde_json::from_str(&content)
31                        .with_context(|| format!("parse migration: {}", path.display()))?
32                } else {
33                    serde_yaml::from_str(&content)
34                        .with_context(|| format!("parse migration: {}", path.display()))?
35                };
36
37                // Validate the migration plan
38                validate_migration_plan(&plan)
39                    .with_context(|| format!("validate migration: {}", path.display()))?;
40
41                plans.push(plan);
42            }
43        }
44    }
45
46    // Sort by version number
47    plans.sort_by_key(|p| p.version);
48    Ok(plans)
49}
50
51/// Load migrations from a specific directory (for compile-time use in macros).
52pub fn load_migrations_from_dir(
53    project_root: Option<PathBuf>,
54) -> Result<Vec<MigrationPlan>, Box<dyn std::error::Error>> {
55    // Locate project root from CARGO_MANIFEST_DIR or use provided path
56    let project_root = if let Some(root) = project_root {
57        root
58    } else {
59        let manifest_dir = env::var("CARGO_MANIFEST_DIR")
60            .map_err(|_| "CARGO_MANIFEST_DIR environment variable not set")?;
61        PathBuf::from(manifest_dir)
62    };
63
64    // Read vespertide.json or use defaults
65    let config = crate::config::load_config_or_default(Some(project_root.clone()))
66        .map_err(|e| format!("Failed to load config: {}", e))?;
67
68    // Read migrations directory
69    let migrations_dir = project_root.join(config.migrations_dir());
70    if !migrations_dir.exists() {
71        return Ok(Vec::new());
72    }
73
74    let mut plans = Vec::new();
75    let entries = fs::read_dir(&migrations_dir)
76        .map_err(|e| format!("Failed to read migrations directory: {}", e))?;
77
78    for entry in entries {
79        let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
80        let path = entry.path();
81        if path.is_file() {
82            let ext = path.extension().and_then(|s| s.to_str());
83            if ext == Some("json") || ext == Some("yaml") || ext == Some("yml") {
84                let content = fs::read_to_string(&path)
85                    .context(format!("Failed to read migration file {}", path.display()))?;
86
87                let plan: MigrationPlan = if ext == Some("json") {
88                    serde_json::from_str(&content).map_err(|e| {
89                        format!("Failed to parse JSON migration {}: {}", path.display(), e)
90                    })?
91                } else {
92                    serde_yaml::from_str(&content).map_err(|e| {
93                        format!("Failed to parse YAML migration {}: {}", path.display(), e)
94                    })?
95                };
96
97                plans.push(plan);
98            }
99        }
100    }
101
102    // Sort by version
103    plans.sort_by_key(|p| p.version);
104    Ok(plans)
105}
106
107/// Load migrations at compile time (for macro use).
108pub fn load_migrations_at_compile_time() -> Result<Vec<MigrationPlan>, Box<dyn std::error::Error>> {
109    load_migrations_from_dir(None)
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use serial_test::serial;
116    use std::fs;
117    use tempfile::TempDir;
118
119    #[test]
120    fn test_load_migrations_from_dir_with_no_migrations_dir() {
121        let temp_dir = TempDir::new().unwrap();
122        let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
123        assert!(result.is_ok());
124        assert_eq!(result.unwrap().len(), 0);
125    }
126
127    #[test]
128    fn test_load_migrations_from_dir_with_empty_migrations_dir() {
129        let temp_dir = TempDir::new().unwrap();
130        let migrations_dir = temp_dir.path().join("migrations");
131        fs::create_dir_all(&migrations_dir).unwrap();
132
133        let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
134        assert!(result.is_ok());
135        assert_eq!(result.unwrap().len(), 0);
136    }
137
138    #[test]
139    fn test_load_migrations_from_dir_with_json_migration() {
140        let temp_dir = TempDir::new().unwrap();
141        let migrations_dir = temp_dir.path().join("migrations");
142        fs::create_dir_all(&migrations_dir).unwrap();
143
144        let migration_content = r#"{
145            "version": 1,
146            "actions": [
147                {
148                    "type": "create_table",
149                    "table": "users",
150                    "columns": [
151                        {
152                            "name": "id",
153                            "type": "integer",
154                            "nullable": false
155                        }
156                    ],
157                    "constraints": []
158                }
159            ]
160        }"#;
161
162        fs::write(migrations_dir.join("0001_test.json"), migration_content).unwrap();
163
164        let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
165        assert!(result.is_ok());
166        let plans = result.unwrap();
167        assert_eq!(plans.len(), 1);
168        assert_eq!(plans[0].version, 1);
169    }
170
171    #[test]
172    fn test_load_migrations_from_dir_sorts_by_version() {
173        let temp_dir = TempDir::new().unwrap();
174        let migrations_dir = temp_dir.path().join("migrations");
175        fs::create_dir_all(&migrations_dir).unwrap();
176
177        let migration1 = r#"{"version": 2, "actions": []}"#;
178        let migration2 = r#"{"version": 1, "actions": []}"#;
179        let migration3 = r#"{"version": 3, "actions": []}"#;
180
181        fs::write(migrations_dir.join("0002_second.json"), migration1).unwrap();
182        fs::write(migrations_dir.join("0001_first.json"), migration2).unwrap();
183        fs::write(migrations_dir.join("0003_third.json"), migration3).unwrap();
184
185        let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
186        assert!(result.is_ok());
187        let plans = result.unwrap();
188        assert_eq!(plans.len(), 3);
189        assert_eq!(plans[0].version, 1);
190        assert_eq!(plans[1].version, 2);
191        assert_eq!(plans[2].version, 3);
192    }
193
194    #[test]
195    fn test_load_migrations_from_dir_with_yaml_migration() {
196        let temp_dir = TempDir::new().unwrap();
197        let migrations_dir = temp_dir.path().join("migrations");
198        fs::create_dir_all(&migrations_dir).unwrap();
199
200        let migration_content = r#"---
201version: 1
202actions:
203  - type: create_table
204    table: users
205    columns:
206      - name: id
207        type: integer
208        nullable: false
209    constraints: []
210"#;
211
212        fs::write(migrations_dir.join("0001_test.yaml"), migration_content).unwrap();
213
214        let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
215        assert!(result.is_ok());
216        let plans = result.unwrap();
217        assert_eq!(plans.len(), 1);
218        assert_eq!(plans[0].version, 1);
219    }
220
221    #[test]
222    fn test_load_migrations_from_dir_with_yml_migration() {
223        let temp_dir = TempDir::new().unwrap();
224        let migrations_dir = temp_dir.path().join("migrations");
225        fs::create_dir_all(&migrations_dir).unwrap();
226
227        let migration_content = r#"---
228version: 1
229actions:
230  - type: create_table
231    table: users
232    columns:
233      - name: id
234        type: integer
235        nullable: false
236    constraints: []
237"#;
238
239        fs::write(migrations_dir.join("0001_test.yml"), migration_content).unwrap();
240
241        let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
242        assert!(result.is_ok());
243        let plans = result.unwrap();
244        assert_eq!(plans.len(), 1);
245        assert_eq!(plans[0].version, 1);
246    }
247
248    #[test]
249    fn test_load_migrations_from_dir_with_invalid_json() {
250        let temp_dir = TempDir::new().unwrap();
251        let migrations_dir = temp_dir.path().join("migrations");
252        fs::create_dir_all(&migrations_dir).unwrap();
253
254        let invalid_json = r#"{"version": 1, "actions": [invalid]}"#;
255        fs::write(migrations_dir.join("0001_invalid.json"), invalid_json).unwrap();
256
257        let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
258        assert!(result.is_err());
259        let err_msg = result.unwrap_err().to_string();
260        assert!(err_msg.contains("Failed to parse JSON migration"));
261    }
262
263    #[test]
264    fn test_load_migrations_from_dir_with_invalid_yaml() {
265        let temp_dir = TempDir::new().unwrap();
266        let migrations_dir = temp_dir.path().join("migrations");
267        fs::create_dir_all(&migrations_dir).unwrap();
268
269        let invalid_yaml = r#"---
270version: 1
271actions:
272  - invalid: [syntax
273"#;
274        fs::write(migrations_dir.join("0001_invalid.yaml"), invalid_yaml).unwrap();
275
276        let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
277        assert!(result.is_err());
278        let err_msg = result.unwrap_err().to_string();
279        assert!(err_msg.contains("Failed to parse YAML migration"));
280    }
281
282    #[test]
283    fn test_load_migrations_from_dir_with_unreadable_file() {
284        // Note: Testing file read errors (line 85) is extremely difficult in unit tests
285        // because it requires actual I/O errors like:
286        // - Disk failures
287        // - Permission issues
288        // - File locks from other processes
289        // - Network filesystem issues
290        //
291        // The error handling code path at line 85 exists and will be executed
292        // in real-world scenarios when file read errors occur.
293        // The format! macro and error message construction are tested through
294        // other error paths (invalid JSON/YAML parsing).
295        //
296        // For now, we verify the function works correctly with valid files.
297        let temp_dir = TempDir::new().unwrap();
298        let migrations_dir = temp_dir.path().join("migrations");
299        fs::create_dir_all(&migrations_dir).unwrap();
300
301        let file_path = migrations_dir.join("0001_test.json");
302        fs::write(&file_path, r#"{"version": 1, "actions": []}"#).unwrap();
303
304        let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf()));
305        assert!(result.is_ok());
306    }
307
308    #[test]
309    #[serial]
310    fn test_load_migrations_from_dir_without_project_root() {
311        // Save the original value
312        let original = env::var("CARGO_MANIFEST_DIR").ok();
313
314        // Remove CARGO_MANIFEST_DIR to test the error path
315        // Note: remove_var is unsafe in multi-threaded environments,
316        // but serial_test ensures tests run sequentially
317        unsafe {
318            env::remove_var("CARGO_MANIFEST_DIR");
319        }
320
321        let result = load_migrations_from_dir(None);
322        assert!(result.is_err());
323        let err_msg = result.unwrap_err().to_string();
324        assert!(err_msg.contains("CARGO_MANIFEST_DIR environment variable not set"));
325
326        // Restore the original value if it existed
327        if let Some(val) = original {
328            unsafe {
329                env::set_var("CARGO_MANIFEST_DIR", val);
330            }
331        }
332    }
333
334    #[test]
335    fn test_load_migrations_at_compile_time() {
336        // This function just calls load_migrations_from_dir(None)
337        // We can't easily test it without CARGO_MANIFEST_DIR, but we can verify
338        // it doesn't panic
339        let result = load_migrations_at_compile_time();
340        // This might succeed if CARGO_MANIFEST_DIR is set (like in cargo test)
341        // or fail if it's not set
342        // Either way, we're testing the code path
343        let _ = result;
344    }
345}