use std::cmp::Ordering;
use std::fmt;
use std::sync::LazyLock;
use regex_lite::Regex;
use crate::checksum::calculate_checksum;
use crate::directive::{self, MigrationDirectives};
use crate::error::{Result, WaypointError};
use crate::hooks;
static VERSIONED_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^V([\d._]+)__(.+)$").unwrap());
static UNDO_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^U([\d._]+)__(.+)$").unwrap());
static REPEATABLE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^R__(.+)$").unwrap());
#[derive(Debug, Clone)]
pub struct MigrationVersion {
pub segments: Vec<u64>,
pub raw: String,
}
impl MigrationVersion {
pub fn normalized(&self) -> &[u64] {
let end = self
.segments
.iter()
.rposition(|&s| s != 0)
.map_or(0, |i| i + 1);
&self.segments[..end]
}
pub fn parse(raw: &str) -> Result<Self> {
if raw.is_empty() {
return Err(WaypointError::MigrationParseError(
"Version string is empty".to_string(),
));
}
let segments: std::result::Result<Vec<u64>, _> =
raw.split(['.', '_']).map(|s| s.parse::<u64>()).collect();
let segments = segments.map_err(|e| {
WaypointError::MigrationParseError(format!(
"Invalid version segment in '{}': {}",
raw, e
))
})?;
Ok(MigrationVersion {
segments,
raw: raw.to_string(),
})
}
}
impl Ord for MigrationVersion {
fn cmp(&self, other: &Self) -> Ordering {
self.normalized().cmp(other.normalized())
}
}
impl PartialOrd for MigrationVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for MigrationVersion {
fn eq(&self, other: &Self) -> bool {
self.normalized() == other.normalized()
}
}
impl Eq for MigrationVersion {}
impl std::hash::Hash for MigrationVersion {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.normalized().hash(state);
}
}
impl fmt::Display for MigrationVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.raw)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrationType {
Versioned,
Repeatable,
Undo,
}
impl fmt::Display for MigrationType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MigrationType::Versioned => write!(f, "SQL"),
MigrationType::Repeatable => write!(f, "SQL_REPEATABLE"),
MigrationType::Undo => write!(f, "UNDO_SQL"),
}
}
}
#[derive(Debug, Clone)]
pub enum MigrationKind {
Versioned(MigrationVersion),
Repeatable,
Undo(MigrationVersion),
}
#[derive(Debug, Clone)]
pub struct ResolvedMigration {
pub kind: MigrationKind,
pub description: String,
pub script: String,
pub checksum: i32,
pub sql: String,
pub directives: MigrationDirectives,
}
impl ResolvedMigration {
pub fn version(&self) -> Option<&MigrationVersion> {
match &self.kind {
MigrationKind::Versioned(v) | MigrationKind::Undo(v) => Some(v),
MigrationKind::Repeatable => None,
}
}
pub fn migration_type(&self) -> MigrationType {
match &self.kind {
MigrationKind::Versioned(_) => MigrationType::Versioned,
MigrationKind::Repeatable => MigrationType::Repeatable,
MigrationKind::Undo(_) => MigrationType::Undo,
}
}
pub fn is_versioned(&self) -> bool {
matches!(&self.kind, MigrationKind::Versioned(_))
}
pub fn is_undo(&self) -> bool {
matches!(&self.kind, MigrationKind::Undo(_))
}
}
pub fn parse_migration_filename(filename: &str) -> Result<(MigrationKind, String)> {
let stem = filename.strip_suffix(".sql").ok_or_else(|| {
WaypointError::MigrationParseError(format!(
"Migration file '{}' does not have .sql extension",
filename
))
})?;
if let Some(caps) = VERSIONED_RE.captures(stem) {
let version_str = caps.get(1).unwrap().as_str();
let description = caps.get(2).unwrap().as_str().replace('_', " ");
let version = MigrationVersion::parse(version_str)?;
Ok((MigrationKind::Versioned(version), description))
} else if let Some(caps) = UNDO_RE.captures(stem) {
let version_str = caps.get(1).unwrap().as_str();
let description = caps.get(2).unwrap().as_str().replace('_', " ");
let version = MigrationVersion::parse(version_str)?;
Ok((MigrationKind::Undo(version), description))
} else if let Some(caps) = REPEATABLE_RE.captures(stem) {
let description = caps.get(1).unwrap().as_str().replace('_', " ");
Ok((MigrationKind::Repeatable, description))
} else {
Err(WaypointError::MigrationParseError(format!(
"Migration file '{}' does not match V{{version}}__{{description}}.sql, U{{version}}__{{description}}.sql, or R__{{description}}.sql pattern",
filename
)))
}
}
pub fn scan_migrations(locations: &[std::path::PathBuf]) -> Result<Vec<ResolvedMigration>> {
let mut migrations = Vec::new();
for location in locations {
if !location.exists() {
log::warn!("Migration location does not exist: {}", location.display());
continue;
}
let entries = std::fs::read_dir(location).map_err(|e| {
WaypointError::IoError(std::io::Error::new(
e.kind(),
format!(
"Failed to read migration directory '{}': {}",
location.display(),
e
),
))
})?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name.to_string(),
None => continue,
};
if !filename.ends_with(".sql") {
continue;
}
if hooks::is_hook_file(&filename) {
continue;
}
if !filename.starts_with('V')
&& !filename.starts_with('U')
&& !filename.starts_with('R')
{
log::warn!(
"Ignoring '{}': migration filenames must start with V (versioned), \
U (undo) or R (repeatable), and the prefix is case-sensitive.",
filename
);
continue;
}
let (kind, description) = match parse_migration_filename(&filename) {
Ok(result) => result,
Err(e) => {
log::warn!("Skipping malformed migration file '{}': {}", filename, e);
continue;
}
};
let sql = std::fs::read_to_string(&path)?;
let checksum = calculate_checksum(&sql);
let directives = directive::parse_directives(&sql);
migrations.push(ResolvedMigration {
kind,
description,
script: filename,
checksum,
sql,
directives,
});
}
}
migrations.sort_by(|a, b| {
fn group_order(kind: &MigrationKind) -> u8 {
match kind {
MigrationKind::Versioned(_) => 0,
MigrationKind::Undo(_) => 1,
MigrationKind::Repeatable => 2,
}
}
let ga = group_order(&a.kind);
let gb = group_order(&b.kind);
if ga != gb {
return ga.cmp(&gb);
}
match (&a.kind, &b.kind) {
(MigrationKind::Versioned(va), MigrationKind::Versioned(vb)) => va.cmp(vb),
(MigrationKind::Undo(va), MigrationKind::Undo(vb)) => va.cmp(vb),
(MigrationKind::Repeatable, MigrationKind::Repeatable) => {
a.description.cmp(&b.description)
}
_ => Ordering::Equal,
}
});
let mut seen_versions: std::collections::HashMap<(bool, Vec<u64>), &str> =
std::collections::HashMap::new();
for m in &migrations {
if let Some(v) = m.version() {
let key = (m.is_versioned(), v.normalized().to_vec());
if let Some(previous) = seen_versions.insert(key, m.script.as_str()) {
return Err(WaypointError::ValidationFailed(format!(
"Duplicate migration version '{}' found in files '{}' and '{}'. \
Each version must be unique (note that '1', '1.0' and '1.0.0' \
are the same version).",
v.raw, previous, m.script
)));
}
}
}
Ok(migrations)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_parsing() {
let v = MigrationVersion::parse("1").unwrap();
assert_eq!(v.segments, vec![1]);
let v = MigrationVersion::parse("1.2.3").unwrap();
assert_eq!(v.segments, vec![1, 2, 3]);
let v = MigrationVersion::parse("1_2_3").unwrap();
assert_eq!(v.segments, vec![1, 2, 3]);
}
#[test]
fn test_version_ordering() {
let v1 = MigrationVersion::parse("1").unwrap();
let v2 = MigrationVersion::parse("2").unwrap();
let v1_9 = MigrationVersion::parse("1.9").unwrap();
let v1_10 = MigrationVersion::parse("1.10").unwrap();
let v1_2 = MigrationVersion::parse("1.2").unwrap();
let v1_2_0 = MigrationVersion::parse("1.2.0").unwrap();
assert!(v1 < v2);
assert!(v1_9 < v1_10); assert!(v1_2 < v1_9);
assert_eq!(v1_2.cmp(&v1_2_0), Ordering::Equal); }
#[test]
fn test_version_eq_matches_ord() {
let cases = [("1", "1.0"), ("1.2", "1.2.0"), ("1", "1.0.0"), ("0", "0.0")];
for (a, b) in cases {
let va = MigrationVersion::parse(a).unwrap();
let vb = MigrationVersion::parse(b).unwrap();
assert_eq!(va.cmp(&vb), Ordering::Equal, "{a} vs {b}");
assert_eq!(va, vb, "{a} vs {b} should be equal");
}
let v1 = MigrationVersion::parse("1").unwrap();
let v2 = MigrationVersion::parse("2").unwrap();
assert_ne!(v1, v2);
assert_ne!(v1.cmp(&v2), Ordering::Equal);
}
#[test]
fn test_version_hash_matches_eq() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(MigrationVersion::parse("1.0").unwrap());
assert!(!set.insert(MigrationVersion::parse("1").unwrap()));
assert!(set.insert(MigrationVersion::parse("1.1").unwrap()));
assert_eq!(set.len(), 2);
}
#[test]
fn test_version_normalized() {
assert_eq!(MigrationVersion::parse("1.0.0").unwrap().normalized(), &[1]);
assert_eq!(
MigrationVersion::parse("1.2.0").unwrap().normalized(),
&[1, 2]
);
assert!(
MigrationVersion::parse("0.0")
.unwrap()
.normalized()
.is_empty()
);
assert_eq!(
MigrationVersion::parse("1.0.3").unwrap().normalized(),
&[1, 0, 3]
);
}
#[test]
fn test_version_parse_error() {
assert!(MigrationVersion::parse("").is_err());
assert!(MigrationVersion::parse("abc").is_err());
}
#[test]
fn test_parse_versioned_filename() {
let (kind, desc) = parse_migration_filename("V1__Create_users.sql").unwrap();
match kind {
MigrationKind::Versioned(v) => assert_eq!(v.segments, vec![1]),
_ => panic!("Expected Versioned"),
}
assert_eq!(desc, "Create users");
}
#[test]
fn test_parse_versioned_dotted_version() {
let (kind, desc) = parse_migration_filename("V1.2.3__Add_column.sql").unwrap();
match kind {
MigrationKind::Versioned(v) => assert_eq!(v.segments, vec![1, 2, 3]),
_ => panic!("Expected Versioned"),
}
assert_eq!(desc, "Add column");
}
#[test]
fn test_parse_repeatable_filename() {
let (kind, desc) = parse_migration_filename("R__Create_user_view.sql").unwrap();
assert!(matches!(kind, MigrationKind::Repeatable));
assert_eq!(desc, "Create user view");
}
#[test]
fn test_parse_invalid_filename() {
assert!(parse_migration_filename("random.sql").is_err());
assert!(parse_migration_filename("V1_missing_separator.sql").is_err());
assert!(parse_migration_filename("V1__no_ext").is_err());
}
#[test]
fn test_parse_undo_filename() {
let (kind, desc) = parse_migration_filename("U1__Create_users.sql").unwrap();
match kind {
MigrationKind::Undo(v) => assert_eq!(v.segments, vec![1]),
_ => panic!("Expected Undo"),
}
assert_eq!(desc, "Create users");
}
#[test]
fn test_parse_undo_dotted_version() {
let (kind, desc) = parse_migration_filename("U1.2.3__Add_column.sql").unwrap();
match kind {
MigrationKind::Undo(v) => assert_eq!(v.segments, vec![1, 2, 3]),
_ => panic!("Expected Undo"),
}
assert_eq!(desc, "Add column");
}
#[test]
fn test_malformed_filename_is_skipped() {
assert!(parse_migration_filename("random.sql").is_err());
assert!(parse_migration_filename("V1_missing_separator.sql").is_err());
}
#[test]
fn test_undo_is_undo() {
let m = ResolvedMigration {
kind: MigrationKind::Undo(MigrationVersion::parse("1").unwrap()),
description: "test".to_string(),
script: "U1__test.sql".to_string(),
checksum: 0,
sql: String::new(),
directives: MigrationDirectives::default(),
};
assert!(m.is_undo());
assert!(!m.is_versioned());
assert_eq!(m.migration_type(), MigrationType::Undo);
assert_eq!(m.migration_type().to_string(), "UNDO_SQL");
}
}