1use crate::config::MigrationsConfig;
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10pub const KNOWN_PATTERNS: &[(&str, &str, &str)] = &[
17 ("prisma/migrations", "Prisma", "Prisma ORM migrations"),
18 ("db/migrate", "Rails", "Rails Active Record migrations"),
19 ("migrations", "Generic", "Standard migrations directory"),
20 (
21 "alembic/versions",
22 "Alembic",
23 "SQLAlchemy Alembic migrations",
24 ),
25 ("drizzle", "Drizzle", "Drizzle ORM migrations"),
26 ("supabase/migrations", "Supabase", "Supabase migrations"),
27 ("flyway/sql", "Flyway", "Flyway SQL migrations"),
28 ("liquibase/changelogs", "Liquibase", "Liquibase changelogs"),
29 ("src/migrations", "TypeORM", "TypeORM/Sequelize migrations"),
30 (
31 "database/migrations",
32 "Laravel",
33 "Laravel Eloquent migrations",
34 ),
35 ("db/migrations", "Knex", "Knex.js migrations"),
36 ("diesel/migrations", "Diesel", "Diesel Rust ORM migrations"),
37 (
38 "schema/migrations",
39 "Generic",
40 "Schema migrations directory",
41 ),
42];
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct DiscoveredMigrations {
51 pub framework: String,
53 pub path: PathBuf,
55 pub sql_file_count: usize,
57 pub total_file_count: usize,
59 pub matched_pattern: Option<String>,
61 pub from_config: bool,
63 pub description: String,
65 pub sql_files: Vec<PathBuf>,
67}
68
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
71pub struct DiscoveryReport {
72 pub root: PathBuf,
74 pub discovered: Vec<DiscoveredMigrations>,
76 pub total_sql_files: usize,
78 pub patterns_searched: Vec<String>,
80}
81
82pub struct MigrationDiscovery {
88 config: MigrationsConfig,
89}
90
91impl MigrationDiscovery {
92 pub fn new(config: MigrationsConfig) -> Self {
94 Self { config }
95 }
96
97 pub fn with_defaults() -> Self {
99 Self::new(MigrationsConfig::default())
100 }
101
102 pub fn discover(&self, root: &Path) -> DiscoveryReport {
110 let mut report = DiscoveryReport {
111 root: root.to_path_buf(),
112 discovered: Vec::new(),
113 total_sql_files: 0,
114 patterns_searched: self.config.patterns.clone(),
115 };
116
117 let mut seen_paths: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
119
120 for custom_path in &self.config.paths {
122 let full_path = root.join(custom_path);
123 if full_path.is_dir() && !seen_paths.contains(&full_path) {
124 if let Some(discovery) = self.scan_directory(&full_path, "Custom", "", true) {
125 if discovery.sql_file_count > 0 {
126 seen_paths.insert(full_path.clone());
127 report.total_sql_files += discovery.sql_file_count;
128 report.discovered.push(discovery);
129 }
130 }
131 }
132 }
133
134 if self.config.auto_discover {
136 for (pattern, framework, description) in KNOWN_PATTERNS {
137 let full_path = root.join(pattern);
138 if full_path.is_dir() && !seen_paths.contains(&full_path) {
139 if let Some(discovery) =
140 self.scan_directory(&full_path, framework, description, false)
141 {
142 if discovery.sql_file_count > 0 {
143 seen_paths.insert(full_path.clone());
144 report.total_sql_files += discovery.sql_file_count;
145 report.discovered.push(discovery);
146 }
147 }
148 }
149 }
150 }
151
152 for pattern in &self.config.patterns {
154 let full_pattern = root.join(pattern).to_string_lossy().to_string();
155 let full_pattern = full_pattern.replace('\\', "/");
157
158 if let Ok(paths) = glob::glob(&full_pattern) {
159 for entry in paths.flatten() {
160 if let Some(parent) = entry.parent() {
161 let parent_path = parent.to_path_buf();
162 if !seen_paths.contains(&parent_path) {
163 if let Some(mut discovery) =
164 self.scan_directory(&parent_path, "Pattern", "", false)
165 {
166 if discovery.sql_file_count > 0 {
167 discovery.matched_pattern = Some(pattern.clone());
168 seen_paths.insert(parent_path);
169 report.total_sql_files += discovery.sql_file_count;
170 report.discovered.push(discovery);
171 }
172 }
173 }
174 }
175 }
176 }
177 }
178
179 report.discovered.sort_by(|a, b| a.path.cmp(&b.path));
181
182 report
183 }
184
185 fn scan_directory(
187 &self,
188 dir: &Path,
189 framework: &str,
190 description: &str,
191 from_config: bool,
192 ) -> Option<DiscoveredMigrations> {
193 if !dir.is_dir() {
194 return None;
195 }
196
197 let sql_files = self.find_sql_files(dir);
198 let total_file_count = self.count_all_files(dir);
199
200 Some(DiscoveredMigrations {
201 framework: framework.to_string(),
202 path: dir.to_path_buf(),
203 sql_file_count: sql_files.len(),
204 total_file_count,
205 matched_pattern: None,
206 from_config,
207 description: if description.is_empty() {
208 format!("{} migrations", framework)
209 } else {
210 description.to_string()
211 },
212 sql_files,
213 })
214 }
215
216 fn find_sql_files(&self, dir: &Path) -> Vec<PathBuf> {
218 let pattern = dir.join("**/*.sql").to_string_lossy().to_string();
219 let pattern = pattern.replace('\\', "/");
220
221 glob::glob(&pattern)
222 .map(|paths| paths.flatten().collect())
223 .unwrap_or_default()
224 }
225
226 fn count_all_files(&self, dir: &Path) -> usize {
228 std::fs::read_dir(dir)
229 .map(|entries| {
230 entries
231 .filter_map(|e| e.ok())
232 .filter(|e| e.path().is_file())
233 .count()
234 })
235 .unwrap_or(0)
236 }
237}
238
239pub fn discover_migrations(root: &Path) -> DiscoveryReport {
245 MigrationDiscovery::with_defaults().discover(root)
246}
247
248pub fn is_migration_directory(path: &Path) -> bool {
250 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
251 let lower = name.to_lowercase();
252
253 if lower.contains("migration") || lower == "migrate" || lower == "versions" {
255 return true;
256 }
257
258 for (pattern, _, _) in KNOWN_PATTERNS {
260 if path.ends_with(pattern) {
261 return true;
262 }
263 }
264
265 false
266}
267
268#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn test_default_discovery() {
278 let discovery = MigrationDiscovery::with_defaults();
279 assert!(discovery.config.auto_discover);
280 assert!(!discovery.config.patterns.is_empty());
281 }
282
283 #[test]
284 fn test_is_migration_directory() {
285 assert!(is_migration_directory(Path::new("migrations")));
286 assert!(is_migration_directory(Path::new("db/migrate")));
287 assert!(is_migration_directory(Path::new("alembic/versions")));
288 assert!(!is_migration_directory(Path::new("src")));
289 assert!(!is_migration_directory(Path::new("lib")));
290 }
291
292 #[test]
293 fn test_known_patterns_have_required_fields() {
294 for (pattern, framework, description) in KNOWN_PATTERNS {
295 assert!(!pattern.is_empty(), "Pattern should not be empty");
296 assert!(!framework.is_empty(), "Framework should not be empty");
297 assert!(!description.is_empty(), "Description should not be empty");
298 }
299 }
300
301 #[test]
302 fn test_discovery_empty_dir() {
303 let temp_dir = std::env::temp_dir().join("schema-risk-test-empty");
304 let _ = std::fs::create_dir_all(&temp_dir);
305
306 let discovery = MigrationDiscovery::with_defaults();
307 let report = discovery.discover(&temp_dir);
308
309 assert!(report.discovered.is_empty());
310 assert_eq!(report.total_sql_files, 0);
311
312 let _ = std::fs::remove_dir_all(&temp_dir);
313 }
314}