use crate::config::MigrationsConfig;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const KNOWN_PATTERNS: &[(&str, &str, &str)] = &[
("prisma/migrations", "Prisma", "Prisma ORM migrations"),
("db/migrate", "Rails", "Rails Active Record migrations"),
("migrations", "Generic", "Standard migrations directory"),
(
"alembic/versions",
"Alembic",
"SQLAlchemy Alembic migrations",
),
("drizzle", "Drizzle", "Drizzle ORM migrations"),
("supabase/migrations", "Supabase", "Supabase migrations"),
("flyway/sql", "Flyway", "Flyway SQL migrations"),
("liquibase/changelogs", "Liquibase", "Liquibase changelogs"),
("src/migrations", "TypeORM", "TypeORM/Sequelize migrations"),
(
"database/migrations",
"Laravel",
"Laravel Eloquent migrations",
),
("db/migrations", "Knex", "Knex.js migrations"),
("diesel/migrations", "Diesel", "Diesel Rust ORM migrations"),
(
"schema/migrations",
"Generic",
"Schema migrations directory",
),
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredMigrations {
pub framework: String,
pub path: PathBuf,
pub sql_file_count: usize,
pub total_file_count: usize,
pub matched_pattern: Option<String>,
pub from_config: bool,
pub description: String,
pub sql_files: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiscoveryReport {
pub root: PathBuf,
pub discovered: Vec<DiscoveredMigrations>,
pub total_sql_files: usize,
pub patterns_searched: Vec<String>,
}
pub struct MigrationDiscovery {
config: MigrationsConfig,
}
impl MigrationDiscovery {
pub fn new(config: MigrationsConfig) -> Self {
Self { config }
}
pub fn with_defaults() -> Self {
Self::new(MigrationsConfig::default())
}
pub fn discover(&self, root: &Path) -> DiscoveryReport {
let mut report = DiscoveryReport {
root: root.to_path_buf(),
discovered: Vec::new(),
total_sql_files: 0,
patterns_searched: self.config.patterns.clone(),
};
let mut seen_paths: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
for custom_path in &self.config.paths {
let full_path = root.join(custom_path);
if full_path.is_dir() && !seen_paths.contains(&full_path) {
if let Some(discovery) = self.scan_directory(&full_path, "Custom", "", true) {
if discovery.sql_file_count > 0 {
seen_paths.insert(full_path.clone());
report.total_sql_files += discovery.sql_file_count;
report.discovered.push(discovery);
}
}
}
}
if self.config.auto_discover {
for (pattern, framework, description) in KNOWN_PATTERNS {
let full_path = root.join(pattern);
if full_path.is_dir() && !seen_paths.contains(&full_path) {
if let Some(discovery) =
self.scan_directory(&full_path, framework, description, false)
{
if discovery.sql_file_count > 0 {
seen_paths.insert(full_path.clone());
report.total_sql_files += discovery.sql_file_count;
report.discovered.push(discovery);
}
}
}
}
}
for pattern in &self.config.patterns {
let full_pattern = root.join(pattern).to_string_lossy().to_string();
let full_pattern = full_pattern.replace('\\', "/");
if let Ok(paths) = glob::glob(&full_pattern) {
for entry in paths.flatten() {
if let Some(parent) = entry.parent() {
let parent_path = parent.to_path_buf();
if !seen_paths.contains(&parent_path) {
if let Some(mut discovery) =
self.scan_directory(&parent_path, "Pattern", "", false)
{
if discovery.sql_file_count > 0 {
discovery.matched_pattern = Some(pattern.clone());
seen_paths.insert(parent_path);
report.total_sql_files += discovery.sql_file_count;
report.discovered.push(discovery);
}
}
}
}
}
}
}
report.discovered.sort_by(|a, b| a.path.cmp(&b.path));
report
}
fn scan_directory(
&self,
dir: &Path,
framework: &str,
description: &str,
from_config: bool,
) -> Option<DiscoveredMigrations> {
if !dir.is_dir() {
return None;
}
let sql_files = self.find_sql_files(dir);
let total_file_count = self.count_all_files(dir);
Some(DiscoveredMigrations {
framework: framework.to_string(),
path: dir.to_path_buf(),
sql_file_count: sql_files.len(),
total_file_count,
matched_pattern: None,
from_config,
description: if description.is_empty() {
format!("{} migrations", framework)
} else {
description.to_string()
},
sql_files,
})
}
fn find_sql_files(&self, dir: &Path) -> Vec<PathBuf> {
let pattern = dir.join("**/*.sql").to_string_lossy().to_string();
let pattern = pattern.replace('\\', "/");
glob::glob(&pattern)
.map(|paths| paths.flatten().collect())
.unwrap_or_default()
}
fn count_all_files(&self, dir: &Path) -> usize {
std::fs::read_dir(dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file())
.count()
})
.unwrap_or(0)
}
}
pub fn discover_migrations(root: &Path) -> DiscoveryReport {
MigrationDiscovery::with_defaults().discover(root)
}
pub fn is_migration_directory(path: &Path) -> bool {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let lower = name.to_lowercase();
if lower.contains("migration") || lower == "migrate" || lower == "versions" {
return true;
}
for (pattern, _, _) in KNOWN_PATTERNS {
if path.ends_with(pattern) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_discovery() {
let discovery = MigrationDiscovery::with_defaults();
assert!(discovery.config.auto_discover);
assert!(!discovery.config.patterns.is_empty());
}
#[test]
fn test_is_migration_directory() {
assert!(is_migration_directory(Path::new("migrations")));
assert!(is_migration_directory(Path::new("db/migrate")));
assert!(is_migration_directory(Path::new("alembic/versions")));
assert!(!is_migration_directory(Path::new("src")));
assert!(!is_migration_directory(Path::new("lib")));
}
#[test]
fn test_known_patterns_have_required_fields() {
for (pattern, framework, description) in KNOWN_PATTERNS {
assert!(!pattern.is_empty(), "Pattern should not be empty");
assert!(!framework.is_empty(), "Framework should not be empty");
assert!(!description.is_empty(), "Description should not be empty");
}
}
#[test]
fn test_discovery_empty_dir() {
let temp_dir = std::env::temp_dir().join("schema-risk-test-empty");
let _ = std::fs::create_dir_all(&temp_dir);
let discovery = MigrationDiscovery::with_defaults();
let report = discovery.discover(&temp_dir);
assert!(report.discovered.is_empty());
assert_eq!(report.total_sql_files, 0);
let _ = std::fs::remove_dir_all(&temp_dir);
}
}