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')
277 && !filename.starts_with('U')
278 && !filename.starts_with('R')
279 {
280 continue;
281 }
282
283 let (kind, description) = match parse_migration_filename(&filename) {
284 Ok(result) => result,
285 Err(e) => {
286 log::warn!("Skipping malformed migration file '{}': {}", filename, e);
287 continue;
288 }
289 };
290 let sql = std::fs::read_to_string(&path)?;
291 let checksum = calculate_checksum(&sql);
292 let directives = directive::parse_directives(&sql);
293
294 migrations.push(ResolvedMigration {
295 kind,
296 description,
297 script: filename,
298 checksum,
299 sql,
300 directives,
301 });
302 }
303 }
304
305 migrations.sort_by(|a, b| {
307 fn group_order(kind: &MigrationKind) -> u8 {
309 match kind {
310 MigrationKind::Versioned(_) => 0,
311 MigrationKind::Undo(_) => 1,
312 MigrationKind::Repeatable => 2,
313 }
314 }
315 let ga = group_order(&a.kind);
316 let gb = group_order(&b.kind);
317 if ga != gb {
318 return ga.cmp(&gb);
319 }
320 match (&a.kind, &b.kind) {
321 (MigrationKind::Versioned(va), MigrationKind::Versioned(vb)) => va.cmp(vb),
322 (MigrationKind::Undo(va), MigrationKind::Undo(vb)) => va.cmp(vb),
323 (MigrationKind::Repeatable, MigrationKind::Repeatable) => {
324 a.description.cmp(&b.description)
325 }
326 _ => Ordering::Equal,
327 }
328 });
329
330 let mut seen_versions: std::collections::HashMap<(bool, Vec<u64>), &str> =
335 std::collections::HashMap::new();
336 for m in &migrations {
337 if let Some(v) = m.version() {
338 let key = (m.is_versioned(), v.normalized().to_vec());
339 if let Some(previous) = seen_versions.insert(key, m.script.as_str()) {
340 return Err(WaypointError::ValidationFailed(format!(
341 "Duplicate migration version '{}' found in files '{}' and '{}'. \
342 Each version must be unique (note that '1', '1.0' and '1.0.0' \
343 are the same version).",
344 v.raw, previous, m.script
345 )));
346 }
347 }
348 }
349
350 Ok(migrations)
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn test_version_parsing() {
359 let v = MigrationVersion::parse("1").unwrap();
360 assert_eq!(v.segments, vec![1]);
361
362 let v = MigrationVersion::parse("1.2.3").unwrap();
363 assert_eq!(v.segments, vec![1, 2, 3]);
364
365 let v = MigrationVersion::parse("1_2_3").unwrap();
366 assert_eq!(v.segments, vec![1, 2, 3]);
367 }
368
369 #[test]
370 fn test_version_ordering() {
371 let v1 = MigrationVersion::parse("1").unwrap();
372 let v2 = MigrationVersion::parse("2").unwrap();
373 let v1_9 = MigrationVersion::parse("1.9").unwrap();
374 let v1_10 = MigrationVersion::parse("1.10").unwrap();
375 let v1_2 = MigrationVersion::parse("1.2").unwrap();
376 let v1_2_0 = MigrationVersion::parse("1.2.0").unwrap();
377
378 assert!(v1 < v2);
379 assert!(v1_9 < v1_10); assert!(v1_2 < v1_9);
381 assert_eq!(v1_2.cmp(&v1_2_0), Ordering::Equal); }
383
384 #[test]
385 fn test_version_eq_matches_ord() {
386 let cases = [("1", "1.0"), ("1.2", "1.2.0"), ("1", "1.0.0"), ("0", "0.0")];
388 for (a, b) in cases {
389 let va = MigrationVersion::parse(a).unwrap();
390 let vb = MigrationVersion::parse(b).unwrap();
391 assert_eq!(va.cmp(&vb), Ordering::Equal, "{a} vs {b}");
392 assert_eq!(va, vb, "{a} vs {b} should be equal");
393 }
394
395 let v1 = MigrationVersion::parse("1").unwrap();
396 let v2 = MigrationVersion::parse("2").unwrap();
397 assert_ne!(v1, v2);
398 assert_ne!(v1.cmp(&v2), Ordering::Equal);
399 }
400
401 #[test]
402 fn test_version_hash_matches_eq() {
403 use std::collections::HashSet;
404 let mut set = HashSet::new();
405 set.insert(MigrationVersion::parse("1.0").unwrap());
406 assert!(!set.insert(MigrationVersion::parse("1").unwrap()));
408 assert!(set.insert(MigrationVersion::parse("1.1").unwrap()));
409 assert_eq!(set.len(), 2);
410 }
411
412 #[test]
413 fn test_version_normalized() {
414 assert_eq!(MigrationVersion::parse("1.0.0").unwrap().normalized(), &[1]);
415 assert_eq!(
416 MigrationVersion::parse("1.2.0").unwrap().normalized(),
417 &[1, 2]
418 );
419 assert!(
420 MigrationVersion::parse("0.0")
421 .unwrap()
422 .normalized()
423 .is_empty()
424 );
425 assert_eq!(
426 MigrationVersion::parse("1.0.3").unwrap().normalized(),
427 &[1, 0, 3]
428 );
429 }
430
431 #[test]
432 fn test_version_parse_error() {
433 assert!(MigrationVersion::parse("").is_err());
434 assert!(MigrationVersion::parse("abc").is_err());
435 }
436
437 #[test]
438 fn test_parse_versioned_filename() {
439 let (kind, desc) = parse_migration_filename("V1__Create_users.sql").unwrap();
440 match kind {
441 MigrationKind::Versioned(v) => assert_eq!(v.segments, vec![1]),
442 _ => panic!("Expected Versioned"),
443 }
444 assert_eq!(desc, "Create users");
445 }
446
447 #[test]
448 fn test_parse_versioned_dotted_version() {
449 let (kind, desc) = parse_migration_filename("V1.2.3__Add_column.sql").unwrap();
450 match kind {
451 MigrationKind::Versioned(v) => assert_eq!(v.segments, vec![1, 2, 3]),
452 _ => panic!("Expected Versioned"),
453 }
454 assert_eq!(desc, "Add column");
455 }
456
457 #[test]
458 fn test_parse_repeatable_filename() {
459 let (kind, desc) = parse_migration_filename("R__Create_user_view.sql").unwrap();
460 assert!(matches!(kind, MigrationKind::Repeatable));
461 assert_eq!(desc, "Create user view");
462 }
463
464 #[test]
465 fn test_parse_invalid_filename() {
466 assert!(parse_migration_filename("random.sql").is_err());
467 assert!(parse_migration_filename("V1_missing_separator.sql").is_err());
468 assert!(parse_migration_filename("V1__no_ext").is_err());
469 }
470
471 #[test]
472 fn test_parse_undo_filename() {
473 let (kind, desc) = parse_migration_filename("U1__Create_users.sql").unwrap();
474 match kind {
475 MigrationKind::Undo(v) => assert_eq!(v.segments, vec![1]),
476 _ => panic!("Expected Undo"),
477 }
478 assert_eq!(desc, "Create users");
479 }
480
481 #[test]
482 fn test_parse_undo_dotted_version() {
483 let (kind, desc) = parse_migration_filename("U1.2.3__Add_column.sql").unwrap();
484 match kind {
485 MigrationKind::Undo(v) => assert_eq!(v.segments, vec![1, 2, 3]),
486 _ => panic!("Expected Undo"),
487 }
488 assert_eq!(desc, "Add column");
489 }
490
491 #[test]
492 fn test_malformed_filename_is_skipped() {
493 assert!(parse_migration_filename("random.sql").is_err());
495 assert!(parse_migration_filename("V1_missing_separator.sql").is_err());
496 }
497
498 #[test]
499 fn test_undo_is_undo() {
500 let m = ResolvedMigration {
501 kind: MigrationKind::Undo(MigrationVersion::parse("1").unwrap()),
502 description: "test".to_string(),
503 script: "U1__test.sql".to_string(),
504 checksum: 0,
505 sql: String::new(),
506 directives: MigrationDirectives::default(),
507 };
508 assert!(m.is_undo());
509 assert!(!m.is_versioned());
510 assert_eq!(m.migration_type(), MigrationType::Undo);
511 assert_eq!(m.migration_type().to_string(), "UNDO_SQL");
512 }
513}