Skip to main content

qail_core/migrate/
named_migration.rs

1//! Named Migration Format
2//!
3//! Provides metadata parsing for migration files with headers:
4//! ```sql
5//! -- migration: 003_add_user_avatar
6//! -- depends: 002_add_users
7//! -- author: orion
8//! ```
9
10use std::collections::HashSet;
11
12/// Metadata for a named migration.
13#[derive(Debug, Clone, Default)]
14pub struct MigrationMeta {
15    /// Migration name (e.g., "003_add_user_avatar")
16    pub name: String,
17    /// Dependencies - migrations that must run before this one
18    pub depends: Vec<String>,
19    /// Author of the migration
20    pub author: Option<String>,
21    /// Creation timestamp
22    pub created: Option<String>,
23}
24
25impl MigrationMeta {
26    /// Create a new migration metadata with just a name.
27    pub fn new(name: &str) -> Self {
28        Self {
29            name: name.to_string(),
30            ..Default::default()
31        }
32    }
33
34    /// Set migration dependencies.
35    pub fn with_depends(mut self, deps: Vec<String>) -> Self {
36        self.depends = deps;
37        self
38    }
39
40    /// Set migration author.
41    pub fn with_author(mut self, author: &str) -> Self {
42        self.author = Some(author.to_string());
43        self
44    }
45
46    /// Generate metadata header for a migration file.
47    pub fn to_header(&self) -> String {
48        let mut lines = vec![format!("-- migration: {}", self.name)];
49
50        if !self.depends.is_empty() {
51            lines.push(format!("-- depends: {}", self.depends.join(", ")));
52        }
53
54        if let Some(ref author) = self.author {
55            lines.push(format!("-- author: {}", author));
56        }
57
58        if let Some(ref created) = self.created {
59            lines.push(format!("-- created: {}", created));
60        }
61
62        lines.push(String::new()); // blank line after header
63        lines.join("\n")
64    }
65}
66
67/// Parse migration metadata from file content.
68/// Looks for lines starting with `-- migration:`, `-- depends:`, `-- author:`, `-- created:`.
69pub fn parse_migration_meta(content: &str) -> Option<MigrationMeta> {
70    let mut meta = MigrationMeta::default();
71    let mut found_name = false;
72
73    for line in content.lines() {
74        let line = line.trim();
75
76        if let Some(name) = line.strip_prefix("-- migration:") {
77            meta.name = name.trim().to_string();
78            found_name = true;
79        } else if let Some(deps) = line.strip_prefix("-- depends:") {
80            meta.depends = deps
81                .split(',')
82                .map(|s| s.trim().to_string())
83                .filter(|s| !s.is_empty())
84                .collect();
85        } else if let Some(author) = line.strip_prefix("-- author:") {
86            meta.author = Some(author.trim().to_string());
87        } else if let Some(created) = line.strip_prefix("-- created:") {
88            meta.created = Some(created.trim().to_string());
89        } else if !line.starts_with("--") && !line.is_empty() {
90            // Stop parsing once we hit non-comment content
91            break;
92        }
93    }
94
95    if found_name { Some(meta) } else { None }
96}
97
98/// Validate migration dependencies (check for cycles and missing deps).
99pub fn validate_dependencies(migrations: &[MigrationMeta]) -> Result<Vec<String>, String> {
100    let mut names = HashSet::new();
101    for mig in migrations {
102        if mig.name.trim().is_empty() {
103            return Err("Migration name must not be empty".to_string());
104        }
105        if !names.insert(mig.name.as_str()) {
106            return Err(format!("Duplicate migration name '{}'", mig.name));
107        }
108    }
109
110    for mig in migrations {
111        for dep in &mig.depends {
112            if !names.contains(dep.as_str()) {
113                return Err(format!(
114                    "Migration '{}' depends on '{}' which doesn't exist",
115                    mig.name, dep
116                ));
117            }
118        }
119    }
120
121    // Topological sort to get execution order
122    let mut order = Vec::new();
123    let mut visited = HashSet::new();
124    let mut in_progress = HashSet::new();
125
126    fn visit<'a>(
127        name: &'a str,
128        migrations: &'a [MigrationMeta],
129        visited: &mut HashSet<&'a str>,
130        in_progress: &mut HashSet<&'a str>,
131        order: &mut Vec<String>,
132    ) -> Result<(), String> {
133        if in_progress.contains(name) {
134            return Err(format!("Circular dependency detected involving '{}'", name));
135        }
136        if visited.contains(name) {
137            return Ok(());
138        }
139
140        in_progress.insert(name);
141
142        if let Some(mig) = migrations.iter().find(|m| m.name == name) {
143            for dep in &mig.depends {
144                visit(dep, migrations, visited, in_progress, order)?;
145            }
146        }
147
148        in_progress.remove(name);
149        visited.insert(name);
150        order.push(name.to_string());
151
152        Ok(())
153    }
154
155    for mig in migrations {
156        visit(
157            &mig.name,
158            migrations,
159            &mut visited,
160            &mut in_progress,
161            &mut order,
162        )?;
163    }
164
165    Ok(order)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_parse_migration_meta() {
174        let content = r#"-- migration: 003_add_avatars
175-- depends: 001_init, 002_add_users
176-- author: orion
177
178+table avatars {
179  id UUID primary_key
180}
181"#;
182        let meta = parse_migration_meta(content).unwrap();
183        assert_eq!(meta.name, "003_add_avatars");
184        assert_eq!(meta.depends, vec!["001_init", "002_add_users"]);
185        assert_eq!(meta.author, Some("orion".to_string()));
186    }
187
188    #[test]
189    fn test_meta_to_header() {
190        let meta = MigrationMeta::new("test_migration")
191            .with_depends(vec!["dep1".to_string()])
192            .with_author("tester");
193
194        let header = meta.to_header();
195        assert!(header.contains("-- migration: test_migration"));
196        assert!(header.contains("-- depends: dep1"));
197        assert!(header.contains("-- author: tester"));
198    }
199
200    #[test]
201    fn test_dependency_validation() {
202        let migs = vec![
203            MigrationMeta::new("001_init"),
204            MigrationMeta::new("002_users").with_depends(vec!["001_init".to_string()]),
205            MigrationMeta::new("003_posts").with_depends(vec!["002_users".to_string()]),
206        ];
207
208        let order = validate_dependencies(&migs).unwrap();
209        assert_eq!(order, vec!["001_init", "002_users", "003_posts"]);
210    }
211
212    #[test]
213    fn dependency_validation_rejects_duplicate_and_empty_names() {
214        let duplicate = vec![
215            MigrationMeta::new("001_init"),
216            MigrationMeta::new("001_init"),
217        ];
218        let err = validate_dependencies(&duplicate)
219            .expect_err("duplicate migration names must fail closed");
220        assert!(err.contains("Duplicate migration name"));
221
222        let empty = vec![MigrationMeta::new("")];
223        let err =
224            validate_dependencies(&empty).expect_err("empty migration names must fail closed");
225        assert!(err.contains("must not be empty"));
226    }
227}