use crate::constants::BASELINE_FILENAME_PREFIX;
use anyhow::Result;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ParsedMigration {
pub path: PathBuf,
pub version: u64,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedBaseline {
pub path: PathBuf,
pub version: u64,
}
pub fn parse_migration_filename(filename: &str) -> Option<(u64, String)> {
if !filename.ends_with(".sql") {
return None;
}
let name_without_ext = &filename[..filename.len() - 4];
let name_without_prefix = name_without_ext
.strip_prefix('V')
.unwrap_or(name_without_ext);
let parts: Vec<&str> = name_without_prefix.splitn(2, '_').collect();
if parts.len() != 2 {
return None;
}
let version = parts[0].parse::<u64>().ok()?;
let description = parts[1].to_string();
Some((version, description))
}
pub fn parse_baseline_filename(filename: &str) -> Option<u64> {
if !filename.starts_with(BASELINE_FILENAME_PREFIX) || !filename.ends_with(".sql") {
return None;
}
let version_str = filename
.strip_prefix(BASELINE_FILENAME_PREFIX)?
.strip_suffix(".sql")?;
let version_str = version_str.strip_prefix('V').unwrap_or(version_str);
version_str.parse::<u64>().ok()
}
pub fn generate_baseline_filename(version: u64) -> String {
format!("{}{}.sql", BASELINE_FILENAME_PREFIX, version)
}
pub fn discover_migrations(migrations_dir: &Path) -> Result<Vec<ParsedMigration>> {
let mut migrations = Vec::new();
if !migrations_dir.exists() {
return Ok(migrations);
}
for entry in std::fs::read_dir(migrations_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "sql")
&& let Some(filename) = path.file_name().and_then(|n| n.to_str())
&& let Some((version, description)) = parse_migration_filename(filename)
{
migrations.push(ParsedMigration {
path,
version,
description,
});
}
}
migrations.sort_by_key(|m| m.version);
Ok(migrations)
}
pub fn discover_baselines(baselines_dir: &Path) -> Result<Vec<ParsedBaseline>> {
let mut baselines = Vec::new();
if !baselines_dir.exists() {
return Ok(baselines);
}
for entry in std::fs::read_dir(baselines_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "sql")
&& let Some(filename) = path.file_name().and_then(|n| n.to_str())
&& let Some(version) = parse_baseline_filename(filename)
{
baselines.push(ParsedBaseline { path, version });
}
}
baselines.sort_by_key(|b| b.version);
Ok(baselines)
}
pub fn find_latest_migration(migrations_dir: &Path) -> Result<Option<ParsedMigration>> {
let migrations = discover_migrations(migrations_dir)?;
Ok(migrations.last().cloned())
}
pub fn find_latest_baseline(baselines_dir: &Path) -> Result<Option<ParsedBaseline>> {
let baselines = discover_baselines(baselines_dir)?;
Ok(baselines.last().cloned())
}
pub fn find_baseline_for_version(
baselines_dir: &Path,
target_version: u64,
) -> Result<Option<ParsedBaseline>> {
let baselines = discover_baselines(baselines_dir)?;
let previous_baseline = baselines
.iter()
.rev()
.find(|b| b.version < target_version)
.cloned();
Ok(previous_baseline)
}
pub fn find_migration_by_version(
migrations_dir: &Path,
version_str: &str,
) -> Result<Option<ParsedMigration>> {
let migrations = discover_migrations(migrations_dir)?;
let version_str = version_str.strip_prefix("V").unwrap_or(version_str);
if let Ok(exact_version) = version_str.parse::<u64>()
&& let Some(migration) = migrations.iter().find(|m| m.version == exact_version)
{
return Ok(Some(migration.clone()));
}
let matching_migrations: Vec<_> = migrations
.iter()
.filter(|m| m.version.to_string().starts_with(version_str))
.collect();
match matching_migrations.len() {
0 => Ok(None),
1 => Ok(Some(matching_migrations[0].clone())),
_ => {
let versions: Vec<String> = matching_migrations
.iter()
.map(|m| m.version.to_string())
.collect();
Err(anyhow::anyhow!(
"Ambiguous migration version '{}'. Matches: {}. Please be more specific.",
version_str,
versions.join(", ")
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_parse_migration_filename() {
assert_eq!(
parse_migration_filename("V1734567890_add_user_index.sql"),
Some((1734567890, "add_user_index".to_string()))
);
assert_eq!(
parse_migration_filename("V1234567890_create_tables.sql"),
Some((1234567890, "create_tables".to_string()))
);
assert_eq!(
parse_migration_filename("1734567890_add_user_index.sql"),
Some((1734567890, "add_user_index".to_string()))
);
assert_eq!(
parse_migration_filename("1234567890_create_tables.sql"),
Some((1234567890, "create_tables".to_string()))
);
assert_eq!(parse_migration_filename("V1734567890_add_user_index"), None); assert_eq!(parse_migration_filename("V1734567890.sql"), None); assert_eq!(parse_migration_filename("1734567890.sql"), None); assert_eq!(parse_migration_filename("Vabc_description.sql"), None); assert_eq!(parse_migration_filename("abc_description.sql"), None); assert_eq!(parse_migration_filename("baseline_V1234567890.sql"), None); }
#[test]
fn test_parse_baseline_filename() {
assert_eq!(
parse_baseline_filename("baseline_1734567890.sql"),
Some(1734567890)
);
assert_eq!(
parse_baseline_filename("baseline_1234567890.sql"),
Some(1234567890)
);
assert_eq!(
parse_baseline_filename("baseline_V1734567890.sql"),
Some(1734567890)
);
assert_eq!(
parse_baseline_filename("baseline_V1234567890.sql"),
Some(1234567890)
);
assert_eq!(parse_baseline_filename("V1734567890_description.sql"), None); assert_eq!(parse_baseline_filename("baseline_V1734567890"), None); assert_eq!(parse_baseline_filename("baseline_Vabc.sql"), None); }
#[test]
fn test_generate_filenames() {
assert_eq!(
generate_baseline_filename(1734567890),
"baseline_1734567890.sql"
);
}
#[test]
fn test_discover_migrations() {
let temp_dir = env::temp_dir().join("pgmt_test_discover_migrations");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
std::fs::write(
temp_dir.join("V1000000000_first_migration.sql"),
"-- First migration (V prefix)",
)
.unwrap();
std::fs::write(
temp_dir.join("2000000000_second_migration.sql"),
"-- Second migration (no prefix)",
)
.unwrap();
std::fs::write(
temp_dir.join("V3000000000_third_migration.sql"),
"-- Third migration (V prefix)",
)
.unwrap();
std::fs::write(temp_dir.join("invalid_file.sql"), "-- Invalid").unwrap();
std::fs::write(temp_dir.join("readme.txt"), "-- Not SQL").unwrap();
let migrations = discover_migrations(&temp_dir).unwrap();
assert_eq!(migrations.len(), 3);
assert_eq!(migrations[0].version, 1000000000);
assert_eq!(migrations[0].description, "first_migration");
assert_eq!(migrations[1].version, 2000000000);
assert_eq!(migrations[1].description, "second_migration");
assert_eq!(migrations[2].version, 3000000000);
assert_eq!(migrations[2].description, "third_migration");
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_discover_baselines() {
let temp_dir = env::temp_dir().join("pgmt_test_discover_baselines");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
std::fs::write(
temp_dir.join("baseline_V1000000000.sql"),
"-- First baseline (old format)",
)
.unwrap();
std::fs::write(
temp_dir.join("baseline_2000000000.sql"),
"-- Second baseline (new format)",
)
.unwrap();
std::fs::write(
temp_dir.join("V1000000000_migration.sql"),
"-- Migration file",
)
.unwrap();
std::fs::write(temp_dir.join("readme.txt"), "-- Not SQL").unwrap();
let baselines = discover_baselines(&temp_dir).unwrap();
assert_eq!(baselines.len(), 2);
assert_eq!(baselines[0].version, 1000000000);
assert_eq!(baselines[1].version, 2000000000);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_find_latest_migration() {
let temp_dir = env::temp_dir().join("pgmt_test_find_latest_migration");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
assert!(find_latest_migration(&temp_dir).unwrap().is_none());
std::fs::write(temp_dir.join("V1000000000_first_migration.sql"), "-- First").unwrap();
std::fs::write(temp_dir.join("V3000000000_third_migration.sql"), "-- Third").unwrap();
std::fs::write(
temp_dir.join("V2000000000_second_migration.sql"),
"-- Second",
)
.unwrap();
let latest = find_latest_migration(&temp_dir).unwrap().unwrap();
assert_eq!(latest.version, 3000000000);
assert_eq!(latest.description, "third_migration");
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_find_baseline_for_version() {
let temp_dir = env::temp_dir().join("pgmt_test_find_baseline_for_version");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
std::fs::write(temp_dir.join("baseline_V1000000000.sql"), "-- Baseline 1").unwrap();
std::fs::write(temp_dir.join("baseline_V2000000000.sql"), "-- Baseline 2").unwrap();
std::fs::write(temp_dir.join("baseline_V4000000000.sql"), "-- Baseline 4").unwrap();
let baseline = find_baseline_for_version(&temp_dir, 3000000000)
.unwrap()
.unwrap();
assert_eq!(baseline.version, 2000000000);
let baseline = find_baseline_for_version(&temp_dir, 1500000000)
.unwrap()
.unwrap();
assert_eq!(baseline.version, 1000000000);
assert!(
find_baseline_for_version(&temp_dir, 500000000)
.unwrap()
.is_none()
);
let _ = std::fs::remove_dir_all(&temp_dir);
}
}