use std::path::{Path, PathBuf};
use crate::{MigrationStore, ChangelogFile};
pub struct RuntimeMigrationStore {
migration_dir: PathBuf,
}
impl RuntimeMigrationStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self {
migration_dir: dir.into(),
}
}
pub fn validate(&self) -> Result<(), String> {
if !self.migration_dir.exists() {
return Err(format!(
"Migration directory does not exist: {:?}",
self.migration_dir
));
}
if !self.migration_dir.is_dir() {
return Err(format!(
"Migration path is not a directory: {:?}",
self.migration_dir
));
}
Ok(())
}
fn load_migrations(&self) -> Vec<ChangelogFile> {
if !self.migration_dir.exists() {
log::warn!(
"Migration directory does not exist: {:?}. Returning empty migration list.",
self.migration_dir
);
return Vec::new();
}
if !self.migration_dir.is_dir() {
log::warn!(
"Migration path is not a directory: {:?}. Returning empty migration list.",
self.migration_dir
);
return Vec::new();
}
let entries = match std::fs::read_dir(&self.migration_dir) {
Ok(entries) => entries,
Err(e) => {
log::warn!(
"Failed to read migration directory {:?}: {}. Returning empty migration list.",
self.migration_dir,
e
);
return Vec::new();
}
};
let mut migrations: Vec<ChangelogFile> = entries
.filter_map(|entry| {
let entry = match entry {
Ok(e) => e,
Err(e) => {
log::warn!("Failed to read directory entry: {}", e);
return None;
}
};
let path = entry.path();
if !path.is_file() {
return None;
}
let filename = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name.to_string(),
None => {
log::warn!("Invalid filename: {:?}", path);
return None;
}
};
if !filename.starts_with('V') || !filename.ends_with(".sql") {
return None;
}
let underscore_pos = match filename.find('_') {
Some(pos) if pos > 1 && pos < filename.len() - "V.sql".len() => pos,
_ => {
log::warn!(
"Invalid migration filename format (missing underscore): {}. Skipping.",
filename
);
return None;
}
};
let version_str = &filename[1..underscore_pos];
if !version_str.chars().all(|c| c.is_ascii_digit()) {
log::warn!(
"Invalid version in filename '{}' (contains non-digit characters). Skipping.",
filename
);
return None;
}
let version: u64 = match version_str.parse() {
Ok(v) => v,
Err(e) => {
log::warn!(
"Failed to parse version from filename '{}': {}. Skipping.",
filename,
e
);
return None;
}
};
Some((path, version, filename))
})
.filter_map(|(path, version, filename)| {
let underscore_pos = filename.find('_').unwrap();
let _name = &filename[(underscore_pos + 1)..(filename.len() - ".sql".len())];
match ChangelogFile::from_path(&path) {
Ok(changelog) => {
if changelog.version != version {
log::warn!(
"Version mismatch in file '{}': filename says {}, file content says {}. Using filename version.",
filename,
version,
changelog.version
);
}
Some(changelog)
}
Err(e) => {
log::warn!(
"Failed to load migration file '{}': {}. Skipping.",
filename,
e
);
None
}
}
})
.collect();
migrations.sort_by(|a, b| a.version.cmp(&b.version));
log::debug!(
"Loaded {} migrations from {:?}",
migrations.len(),
self.migration_dir
);
migrations
}
pub fn migration_dir(&self) -> &Path {
&self.migration_dir
}
}
impl MigrationStore for RuntimeMigrationStore {
fn changelogs(&self) -> Vec<ChangelogFile> {
self.load_migrations()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use std::io::Write;
use tempfile::TempDir;
fn create_test_migration(dir: &Path, version: u64, name: &str, content: &str) {
let filename = format!("V{}_{}.sql", version, name);
let path = dir.join(filename);
let mut file = File::create(&path).unwrap();
writeln!(file, "{}", content).unwrap();
}
#[test]
fn test_runtime_store_creation() {
let store = RuntimeMigrationStore::new("test/migrations");
assert_eq!(store.migration_dir(), Path::new("test/migrations"));
}
#[test]
fn test_validate_success() {
let temp_dir = TempDir::new().unwrap();
let store = RuntimeMigrationStore::new(temp_dir.path());
assert!(store.validate().is_ok());
}
#[test]
fn test_validate_nonexistent_directory() {
let store = RuntimeMigrationStore::new("/nonexistent/path/that/should/not/exist");
assert!(store.validate().is_err());
}
#[test]
fn test_validate_file_not_directory() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("not_a_dir.sql");
File::create(&file_path).unwrap();
let store = RuntimeMigrationStore::new(&file_path);
assert!(store.validate().is_err());
}
#[test]
fn test_load_empty_directory() {
let temp_dir = TempDir::new().unwrap();
let store = RuntimeMigrationStore::new(temp_dir.path());
let migrations = store.changelogs();
assert_eq!(migrations.len(), 0);
}
#[test]
fn test_load_single_migration() {
let temp_dir = TempDir::new().unwrap();
create_test_migration(
temp_dir.path(),
1,
"Create_Users",
"CREATE TABLE users (id INT PRIMARY KEY);"
);
let store = RuntimeMigrationStore::new(temp_dir.path());
let migrations = store.changelogs();
assert_eq!(migrations.len(), 1);
assert_eq!(migrations[0].version, 1);
assert_eq!(migrations[0].name, "Create_Users");
}
#[test]
fn test_load_multiple_migrations_sorted() {
let temp_dir = TempDir::new().unwrap();
create_test_migration(temp_dir.path(), 3, "Add_Index", "CREATE INDEX idx ON users(name);");
create_test_migration(temp_dir.path(), 1, "Create_Users", "CREATE TABLE users (id INT);");
create_test_migration(temp_dir.path(), 2, "Add_Email", "ALTER TABLE users ADD email VARCHAR;");
let store = RuntimeMigrationStore::new(temp_dir.path());
let migrations = store.changelogs();
assert_eq!(migrations.len(), 3);
assert_eq!(migrations[0].version, 1);
assert_eq!(migrations[1].version, 2);
assert_eq!(migrations[2].version, 3);
}
#[test]
fn test_skip_invalid_filenames() {
let temp_dir = TempDir::new().unwrap();
create_test_migration(temp_dir.path(), 1, "Valid", "SELECT 1;");
let invalid_path = temp_dir.path().join("invalid.sql");
File::create(&invalid_path).unwrap();
let invalid_path2 = temp_dir.path().join("V2_test.txt");
File::create(&invalid_path2).unwrap();
let invalid_path3 = temp_dir.path().join("V3.sql");
File::create(&invalid_path3).unwrap();
let store = RuntimeMigrationStore::new(temp_dir.path());
let migrations = store.changelogs();
assert_eq!(migrations.len(), 1);
assert_eq!(migrations[0].version, 1);
}
#[test]
fn test_nonexistent_directory_returns_empty() {
let store = RuntimeMigrationStore::new("/nonexistent/migrations/path");
let migrations = store.changelogs();
assert_eq!(migrations.len(), 0);
}
#[test]
fn test_migration_content_loaded_correctly() {
let temp_dir = TempDir::new().unwrap();
let sql_content = "CREATE TABLE test (\n id INT PRIMARY KEY,\n name VARCHAR(100)\n);";
create_test_migration(temp_dir.path(), 1, "Test_Table", sql_content);
let store = RuntimeMigrationStore::new(temp_dir.path());
let migrations = store.changelogs();
assert_eq!(migrations.len(), 1);
assert!(migrations[0].content.contains("CREATE TABLE test"));
assert!(migrations[0].content.contains("id INT PRIMARY KEY"));
}
}