1use std::cmp::Ordering;
6use std::fmt;
7use std::sync::LazyLock;
8
9use regex_lite::Regex;
10
11use crate::checksum::calculate_checksum;
12use crate::directive::{self, MigrationDirectives};
13use crate::error::{Result, WaypointError};
14use crate::hooks;
15
16static VERSIONED_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^V([\d._]+)__(.+)$").unwrap());
17static UNDO_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^U([\d._]+)__(.+)$").unwrap());
18static REPEATABLE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^R__(.+)$").unwrap());
19
20#[derive(Debug, Clone)]
34pub struct MigrationVersion {
35 pub segments: Vec<u64>,
37 pub raw: String,
39}
40
41impl MigrationVersion {
42 pub fn normalized(&self) -> &[u64] {
48 let end = self
49 .segments
50 .iter()
51 .rposition(|&s| s != 0)
52 .map_or(0, |i| i + 1);
53 &self.segments[..end]
54 }
55
56 pub fn parse(raw: &str) -> Result<Self> {
58 if raw.is_empty() {
59 return Err(WaypointError::MigrationParseError(
60 "Version string is empty".to_string(),
61 ));
62 }
63
64 let segments: std::result::Result<Vec<u64>, _> =
66 raw.split(['.', '_']).map(|s| s.parse::<u64>()).collect();
67
68 let segments = segments.map_err(|e| {
69 WaypointError::MigrationParseError(format!(
70 "Invalid version segment in '{}': {}",
71 raw, e
72 ))
73 })?;
74
75 Ok(MigrationVersion {
76 segments,
77 raw: raw.to_string(),
78 })
79 }
80}
81
82impl Ord for MigrationVersion {
83 fn cmp(&self, other: &Self) -> Ordering {
84 self.normalized().cmp(other.normalized())
86 }
87}
88
89impl PartialOrd for MigrationVersion {
90 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
91 Some(self.cmp(other))
92 }
93}
94
95impl PartialEq for MigrationVersion {
96 fn eq(&self, other: &Self) -> bool {
97 self.normalized() == other.normalized()
98 }
99}
100
101impl Eq for MigrationVersion {}
102
103impl std::hash::Hash for MigrationVersion {
104 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
105 self.normalized().hash(state);
106 }
107}
108
109impl fmt::Display for MigrationVersion {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 write!(f, "{}", self.raw)
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum MigrationType {
118 Versioned,
120 Repeatable,
122 Undo,
124}
125
126impl fmt::Display for MigrationType {
127 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128 match self {
129 MigrationType::Versioned => write!(f, "SQL"),
130 MigrationType::Repeatable => write!(f, "SQL_REPEATABLE"),
131 MigrationType::Undo => write!(f, "UNDO_SQL"),
132 }
133 }
134}
135
136#[derive(Debug, Clone)]
141pub enum MigrationKind {
142 Versioned(MigrationVersion),
144 Repeatable,
146 Undo(MigrationVersion),
148}
149
150#[derive(Debug, Clone)]
152pub struct ResolvedMigration {
153 pub kind: MigrationKind,
155 pub description: String,
157 pub script: String,
159 pub checksum: i32,
161 pub sql: String,
163 pub directives: MigrationDirectives,
165}
166
167impl ResolvedMigration {
168 pub fn version(&self) -> Option<&MigrationVersion> {
170 match &self.kind {
171 MigrationKind::Versioned(v) | MigrationKind::Undo(v) => Some(v),
172 MigrationKind::Repeatable => None,
173 }
174 }
175
176 pub fn migration_type(&self) -> MigrationType {
178 match &self.kind {
179 MigrationKind::Versioned(_) => MigrationType::Versioned,
180 MigrationKind::Repeatable => MigrationType::Repeatable,
181 MigrationKind::Undo(_) => MigrationType::Undo,
182 }
183 }
184
185 pub fn is_versioned(&self) -> bool {
187 matches!(&self.kind, MigrationKind::Versioned(_))
188 }
189
190 pub fn is_undo(&self) -> bool {
192 matches!(&self.kind, MigrationKind::Undo(_))
193 }
194}
195
196pub fn parse_migration_filename(filename: &str) -> Result<(MigrationKind, String)> {
202 let stem = filename.strip_suffix(".sql").ok_or_else(|| {
204 WaypointError::MigrationParseError(format!(
205 "Migration file '{}' does not have .sql extension",
206 filename
207 ))
208 })?;
209
210 if let Some(caps) = VERSIONED_RE.captures(stem) {
211 let version_str = caps.get(1).unwrap().as_str();
212 let description = caps.get(2).unwrap().as_str().replace('_', " ");
213 let version = MigrationVersion::parse(version_str)?;
214 Ok((MigrationKind::Versioned(version), description))
215 } else if let Some(caps) = UNDO_RE.captures(stem) {
216 let version_str = caps.get(1).unwrap().as_str();
217 let description = caps.get(2).unwrap().as_str().replace('_', " ");
218 let version = MigrationVersion::parse(version_str)?;
219 Ok((MigrationKind::Undo(version), description))
220 } else if let Some(caps) = REPEATABLE_RE.captures(stem) {
221 let description = caps.get(1).unwrap().as_str().replace('_', " ");
222 Ok((MigrationKind::Repeatable, description))
223 } else {
224 Err(WaypointError::MigrationParseError(format!(
225 "Migration file '{}' does not match V{{version}}__{{description}}.sql, U{{version}}__{{description}}.sql, or R__{{description}}.sql pattern",
226 filename
227 )))
228 }
229}
230
231pub fn scan_migrations(locations: &[std::path::PathBuf]) -> Result<Vec<ResolvedMigration>> {
233 let mut migrations = Vec::new();
234
235 for location in locations {
236 if !location.exists() {
237 log::warn!("Migration location does not exist: {}", location.display());
238 continue;
239 }
240
241 let entries = std::fs::read_dir(location).map_err(|e| {
242 WaypointError::IoError(std::io::Error::new(
243 e.kind(),
244 format!(
245 "Failed to read migration directory '{}': {}",
246 location.display(),
247 e
248 ),
249 ))
250 })?;
251
252 for entry in entries {
253 let entry = entry?;
254 let path = entry.path();
255
256 if !path.is_file() {
257 continue;
258 }
259
260 let filename = match path.file_name().and_then(|n| n.to_str()) {
261 Some(name) => name.to_string(),
262 None => continue,
263 };
264
265 if !filename.ends_with(".sql") {
267 continue;
268 }
269
270 if hooks::is_hook_file(&filename) {
272 continue;
273 }
274
275 if !filename.starts_with('V')
282 && !filename.starts_with('U')
283 && !filename.starts_with('R')
284 {
285 log::warn!(
286 "Ignoring '{}': migration filenames must start with V (versioned), \
287 U (undo) or R (repeatable), and the prefix is case-sensitive.",
288 filename
289 );
290 continue;
291 }
292
293 let (kind, description) = match parse_migration_filename(&filename) {
294 Ok(result) => result,
295 Err(e) => {
296 log::warn!("Skipping malformed migration file '{}': {}", filename, e);
297 continue;
298 }
299 };
300 let sql = std::fs::read_to_string(&path)?;
301 let checksum = calculate_checksum(&sql);
302 let directives = directive::parse_directives(&sql);
303
304 migrations.push(ResolvedMigration {
305 kind,
306 description,
307 script: filename,
308 checksum,
309 sql,
310 directives,
311 });
312 }
313 }
314
315 migrations.sort_by(|a, b| {
317 fn group_order(kind: &MigrationKind) -> u8 {
319 match kind {
320 MigrationKind::Versioned(_) => 0,
321 MigrationKind::Undo(_) => 1,
322 MigrationKind::Repeatable => 2,
323 }
324 }
325 let ga = group_order(&a.kind);
326 let gb = group_order(&b.kind);
327 if ga != gb {
328 return ga.cmp(&gb);
329 }
330 match (&a.kind, &b.kind) {
331 (MigrationKind::Versioned(va), MigrationKind::Versioned(vb)) => va.cmp(vb),
332 (MigrationKind::Undo(va), MigrationKind::Undo(vb)) => va.cmp(vb),
333 (MigrationKind::Repeatable, MigrationKind::Repeatable) => {
334 a.description.cmp(&b.description)
335 }
336 _ => Ordering::Equal,
337 }
338 });
339
340 let mut seen_versions: std::collections::HashMap<(bool, Vec<u64>), &str> =
345 std::collections::HashMap::new();
346 for m in &migrations {
347 if let Some(v) = m.version() {
348 let key = (m.is_versioned(), v.normalized().to_vec());
349 if let Some(previous) = seen_versions.insert(key, m.script.as_str()) {
350 return Err(WaypointError::ValidationFailed(format!(
351 "Duplicate migration version '{}' found in files '{}' and '{}'. \
352 Each version must be unique (note that '1', '1.0' and '1.0.0' \
353 are the same version).",
354 v.raw, previous, m.script
355 )));
356 }
357 }
358 }
359
360 Ok(migrations)
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn test_version_parsing() {
369 let v = MigrationVersion::parse("1").unwrap();
370 assert_eq!(v.segments, vec![1]);
371
372 let v = MigrationVersion::parse("1.2.3").unwrap();
373 assert_eq!(v.segments, vec![1, 2, 3]);
374
375 let v = MigrationVersion::parse("1_2_3").unwrap();
376 assert_eq!(v.segments, vec![1, 2, 3]);
377 }
378
379 #[test]
380 fn test_version_ordering() {
381 let v1 = MigrationVersion::parse("1").unwrap();
382 let v2 = MigrationVersion::parse("2").unwrap();
383 let v1_9 = MigrationVersion::parse("1.9").unwrap();
384 let v1_10 = MigrationVersion::parse("1.10").unwrap();
385 let v1_2 = MigrationVersion::parse("1.2").unwrap();
386 let v1_2_0 = MigrationVersion::parse("1.2.0").unwrap();
387
388 assert!(v1 < v2);
389 assert!(v1_9 < v1_10); assert!(v1_2 < v1_9);
391 assert_eq!(v1_2.cmp(&v1_2_0), Ordering::Equal); }
393
394 #[test]
395 fn test_version_eq_matches_ord() {
396 let cases = [("1", "1.0"), ("1.2", "1.2.0"), ("1", "1.0.0"), ("0", "0.0")];
398 for (a, b) in cases {
399 let va = MigrationVersion::parse(a).unwrap();
400 let vb = MigrationVersion::parse(b).unwrap();
401 assert_eq!(va.cmp(&vb), Ordering::Equal, "{a} vs {b}");
402 assert_eq!(va, vb, "{a} vs {b} should be equal");
403 }
404
405 let v1 = MigrationVersion::parse("1").unwrap();
406 let v2 = MigrationVersion::parse("2").unwrap();
407 assert_ne!(v1, v2);
408 assert_ne!(v1.cmp(&v2), Ordering::Equal);
409 }
410
411 #[test]
412 fn test_version_hash_matches_eq() {
413 use std::collections::HashSet;
414 let mut set = HashSet::new();
415 set.insert(MigrationVersion::parse("1.0").unwrap());
416 assert!(!set.insert(MigrationVersion::parse("1").unwrap()));
418 assert!(set.insert(MigrationVersion::parse("1.1").unwrap()));
419 assert_eq!(set.len(), 2);
420 }
421
422 #[test]
423 fn test_version_normalized() {
424 assert_eq!(MigrationVersion::parse("1.0.0").unwrap().normalized(), &[1]);
425 assert_eq!(
426 MigrationVersion::parse("1.2.0").unwrap().normalized(),
427 &[1, 2]
428 );
429 assert!(
430 MigrationVersion::parse("0.0")
431 .unwrap()
432 .normalized()
433 .is_empty()
434 );
435 assert_eq!(
436 MigrationVersion::parse("1.0.3").unwrap().normalized(),
437 &[1, 0, 3]
438 );
439 }
440
441 #[test]
442 fn test_version_parse_error() {
443 assert!(MigrationVersion::parse("").is_err());
444 assert!(MigrationVersion::parse("abc").is_err());
445 }
446
447 #[test]
448 fn test_parse_versioned_filename() {
449 let (kind, desc) = parse_migration_filename("V1__Create_users.sql").unwrap();
450 match kind {
451 MigrationKind::Versioned(v) => assert_eq!(v.segments, vec![1]),
452 _ => panic!("Expected Versioned"),
453 }
454 assert_eq!(desc, "Create users");
455 }
456
457 #[test]
458 fn test_parse_versioned_dotted_version() {
459 let (kind, desc) = parse_migration_filename("V1.2.3__Add_column.sql").unwrap();
460 match kind {
461 MigrationKind::Versioned(v) => assert_eq!(v.segments, vec![1, 2, 3]),
462 _ => panic!("Expected Versioned"),
463 }
464 assert_eq!(desc, "Add column");
465 }
466
467 #[test]
468 fn test_parse_repeatable_filename() {
469 let (kind, desc) = parse_migration_filename("R__Create_user_view.sql").unwrap();
470 assert!(matches!(kind, MigrationKind::Repeatable));
471 assert_eq!(desc, "Create user view");
472 }
473
474 #[test]
475 fn test_parse_invalid_filename() {
476 assert!(parse_migration_filename("random.sql").is_err());
477 assert!(parse_migration_filename("V1_missing_separator.sql").is_err());
478 assert!(parse_migration_filename("V1__no_ext").is_err());
479 }
480
481 #[test]
482 fn test_parse_undo_filename() {
483 let (kind, desc) = parse_migration_filename("U1__Create_users.sql").unwrap();
484 match kind {
485 MigrationKind::Undo(v) => assert_eq!(v.segments, vec![1]),
486 _ => panic!("Expected Undo"),
487 }
488 assert_eq!(desc, "Create users");
489 }
490
491 #[test]
492 fn test_parse_undo_dotted_version() {
493 let (kind, desc) = parse_migration_filename("U1.2.3__Add_column.sql").unwrap();
494 match kind {
495 MigrationKind::Undo(v) => assert_eq!(v.segments, vec![1, 2, 3]),
496 _ => panic!("Expected Undo"),
497 }
498 assert_eq!(desc, "Add column");
499 }
500
501 #[test]
502 fn test_malformed_filename_is_skipped() {
503 assert!(parse_migration_filename("random.sql").is_err());
505 assert!(parse_migration_filename("V1_missing_separator.sql").is_err());
506 }
507
508 #[test]
509 fn test_undo_is_undo() {
510 let m = ResolvedMigration {
511 kind: MigrationKind::Undo(MigrationVersion::parse("1").unwrap()),
512 description: "test".to_string(),
513 script: "U1__test.sql".to_string(),
514 checksum: 0,
515 sql: String::new(),
516 directives: MigrationDirectives::default(),
517 };
518 assert!(m.is_undo());
519 assert!(!m.is_versioned());
520 assert_eq!(m.migration_type(), MigrationType::Undo);
521 assert_eq!(m.migration_type().to_string(), "UNDO_SQL");
522 }
523}