Skip to main content

schema_risk/
discovery.rs

1//! Migration directory auto-discovery.
2//!
3//! Automatically detects migration directories for popular frameworks and ORMs.
4//! Supports custom paths via configuration.
5
6use crate::config::MigrationsConfig;
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10// ─────────────────────────────────────────────
11// Known migration patterns
12// ─────────────────────────────────────────────
13
14/// Known migration directory patterns for various frameworks.
15/// Each entry is (relative_path, framework_name, description).
16pub 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// ─────────────────────────────────────────────
45// Discovery result types
46// ─────────────────────────────────────────────
47
48/// A discovered migration directory.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct DiscoveredMigrations {
51    /// The framework or ORM that uses this directory structure.
52    pub framework: String,
53    /// Absolute path to the migration directory.
54    pub path: PathBuf,
55    /// Number of SQL files found.
56    pub sql_file_count: usize,
57    /// Total number of files in the directory.
58    pub total_file_count: usize,
59    /// The glob pattern that matched (if any).
60    pub matched_pattern: Option<String>,
61    /// Whether this was from a custom path in config.
62    pub from_config: bool,
63    /// Human-readable description.
64    pub description: String,
65    /// List of SQL file paths found.
66    pub sql_files: Vec<PathBuf>,
67}
68
69/// Summary of all discovered migrations.
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
71pub struct DiscoveryReport {
72    /// Root directory that was scanned.
73    pub root: PathBuf,
74    /// All discovered migration directories.
75    pub discovered: Vec<DiscoveredMigrations>,
76    /// Total number of SQL files found across all directories.
77    pub total_sql_files: usize,
78    /// Patterns that were searched (from config).
79    pub patterns_searched: Vec<String>,
80}
81
82// ─────────────────────────────────────────────
83// Discovery engine
84// ─────────────────────────────────────────────
85
86/// Migration discovery engine.
87pub struct MigrationDiscovery {
88    config: MigrationsConfig,
89}
90
91impl MigrationDiscovery {
92    /// Create a new discovery engine with the given configuration.
93    pub fn new(config: MigrationsConfig) -> Self {
94        Self { config }
95    }
96
97    /// Create a discovery engine with default configuration.
98    pub fn with_defaults() -> Self {
99        Self::new(MigrationsConfig::default())
100    }
101
102    /// Discover all migration directories from the given root.
103    ///
104    /// # Arguments
105    /// * `root` - The root directory to search from.
106    ///
107    /// # Returns
108    /// A `DiscoveryReport` containing all discovered migration directories.
109    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        // Track already-discovered paths to avoid duplicates
118        let mut seen_paths: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
119
120        // 1. First check custom paths from config (highest priority)
121        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        // 2. Auto-discover known patterns (if enabled)
135        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        // 3. Search using glob patterns from config
153        for pattern in &self.config.patterns {
154            let full_pattern = root.join(pattern).to_string_lossy().to_string();
155            // Normalize path separators for Windows
156            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        // Sort by path for consistent output
180        report.discovered.sort_by(|a, b| a.path.cmp(&b.path));
181
182        report
183    }
184
185    /// Scan a single directory and return discovery info.
186    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    /// Find all SQL files in a directory (recursive).
217    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    /// Count all files in a directory (non-recursive, just for info).
227    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
239// ─────────────────────────────────────────────
240// Convenience functions
241// ─────────────────────────────────────────────
242
243/// Quick discovery using default configuration.
244pub fn discover_migrations(root: &Path) -> DiscoveryReport {
245    MigrationDiscovery::with_defaults().discover(root)
246}
247
248/// Check if a directory looks like it contains migrations.
249pub 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    // Check if the name suggests migrations
254    if lower.contains("migration") || lower == "migrate" || lower == "versions" {
255        return true;
256    }
257
258    // Check if it's a known pattern
259    for (pattern, _, _) in KNOWN_PATTERNS {
260        if path.ends_with(pattern) {
261            return true;
262        }
263    }
264
265    false
266}
267
268// ─────────────────────────────────────────────
269// Tests
270// ─────────────────────────────────────────────
271
272#[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}