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    pub fn with_depends(mut self, deps: Vec<String>) -> Self {
35        self.depends = deps;
36        self
37    }
38
39    pub fn with_author(mut self, author: &str) -> Self {
40        self.author = Some(author.to_string());
41        self
42    }
43
44    /// Generate metadata header for a migration file.
45    pub fn to_header(&self) -> String {
46        let mut lines = vec![format!("-- migration: {}", self.name)];
47
48        if !self.depends.is_empty() {
49            lines.push(format!("-- depends: {}", self.depends.join(", ")));
50        }
51
52        if let Some(ref author) = self.author {
53            lines.push(format!("-- author: {}", author));
54        }
55
56        if let Some(ref created) = self.created {
57            lines.push(format!("-- created: {}", created));
58        }
59
60        lines.push(String::new()); // blank line after header
61        lines.join("\n")
62    }
63}
64
65/// Parse migration metadata from file content.
66///
67/// Looks for lines starting with `-- migration:`, `-- depends:`, `-- author:`, `-- created:`.
68pub fn parse_migration_meta(content: &str) -> Option<MigrationMeta> {
69    let mut meta = MigrationMeta::default();
70    let mut found_name = false;
71
72    for line in content.lines() {
73        let line = line.trim();
74
75        if let Some(name) = line.strip_prefix("-- migration:") {
76            meta.name = name.trim().to_string();
77            found_name = true;
78        } else if let Some(deps) = line.strip_prefix("-- depends:") {
79            meta.depends = deps
80                .split(',')
81                .map(|s| s.trim().to_string())
82                .filter(|s| !s.is_empty())
83                .collect();
84        } else if let Some(author) = line.strip_prefix("-- author:") {
85            meta.author = Some(author.trim().to_string());
86        } else if let Some(created) = line.strip_prefix("-- created:") {
87            meta.created = Some(created.trim().to_string());
88        } else if !line.starts_with("--") && !line.is_empty() {
89            // Stop parsing once we hit non-comment content
90            break;
91        }
92    }
93
94    if found_name { Some(meta) } else { None }
95}
96
97/// Validate migration dependencies (check for cycles and missing deps).
98pub fn validate_dependencies(migrations: &[MigrationMeta]) -> Result<Vec<String>, String> {
99    let names: HashSet<_> = migrations.iter().map(|m| m.name.as_str()).collect();
100
101    for mig in migrations {
102        for dep in &mig.depends {
103            if !names.contains(dep.as_str()) {
104                return Err(format!(
105                    "Migration '{}' depends on '{}' which doesn't exist",
106                    mig.name, dep
107                ));
108            }
109        }
110    }
111
112    // Topological sort to get execution order
113    let mut order = Vec::new();
114    let mut visited = HashSet::new();
115    let mut in_progress = HashSet::new();
116
117    fn visit<'a>(
118        name: &'a str,
119        migrations: &'a [MigrationMeta],
120        visited: &mut HashSet<&'a str>,
121        in_progress: &mut HashSet<&'a str>,
122        order: &mut Vec<String>,
123    ) -> Result<(), String> {
124        if in_progress.contains(name) {
125            return Err(format!("Circular dependency detected involving '{}'", name));
126        }
127        if visited.contains(name) {
128            return Ok(());
129        }
130
131        in_progress.insert(name);
132
133        if let Some(mig) = migrations.iter().find(|m| m.name == name) {
134            for dep in &mig.depends {
135                visit(dep, migrations, visited, in_progress, order)?;
136            }
137        }
138
139        in_progress.remove(name);
140        visited.insert(name);
141        order.push(name.to_string());
142
143        Ok(())
144    }
145
146    for mig in migrations {
147        visit(
148            &mig.name,
149            migrations,
150            &mut visited,
151            &mut in_progress,
152            &mut order,
153        )?;
154    }
155
156    Ok(order)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_parse_migration_meta() {
165        let content = r#"-- migration: 003_add_avatars
166-- depends: 001_init, 002_add_users
167-- author: orion
168
169+table avatars {
170  id UUID primary_key
171}
172"#;
173        let meta = parse_migration_meta(content).unwrap();
174        assert_eq!(meta.name, "003_add_avatars");
175        assert_eq!(meta.depends, vec!["001_init", "002_add_users"]);
176        assert_eq!(meta.author, Some("orion".to_string()));
177    }
178
179    #[test]
180    fn test_meta_to_header() {
181        let meta = MigrationMeta::new("test_migration")
182            .with_depends(vec!["dep1".to_string()])
183            .with_author("tester");
184
185        let header = meta.to_header();
186        assert!(header.contains("-- migration: test_migration"));
187        assert!(header.contains("-- depends: dep1"));
188        assert!(header.contains("-- author: tester"));
189    }
190
191    #[test]
192    fn test_dependency_validation() {
193        let migs = vec![
194            MigrationMeta::new("001_init"),
195            MigrationMeta::new("002_users").with_depends(vec!["001_init".to_string()]),
196            MigrationMeta::new("003_posts").with_depends(vec!["002_users".to_string()]),
197        ];
198
199        let order = validate_dependencies(&migs).unwrap();
200        assert_eq!(order, vec!["001_init", "002_users", "003_posts"]);
201    }
202}