qail_core/migrate/
named_migration.rs1use std::collections::HashSet;
11
12#[derive(Debug, Clone, Default)]
14pub struct MigrationMeta {
15 pub name: String,
17 pub depends: Vec<String>,
19 pub author: Option<String>,
21 pub created: Option<String>,
23}
24
25impl MigrationMeta {
26 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 {
36 self.depends = deps;
37 self
38 }
39
40 pub fn with_author(mut self, author: &str) -> Self {
42 self.author = Some(author.to_string());
43 self
44 }
45
46 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()); lines.join("\n")
64 }
65}
66
67pub 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 break;
92 }
93 }
94
95 if found_name { Some(meta) } else { None }
96}
97
98pub 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 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}