Skip to main content

pgmt/migration/
parsing.rs

1use crate::constants::BASELINE_FILENAME_PREFIX;
2use anyhow::Result;
3use std::path::{Path, PathBuf};
4
5/// Represents a parsed migration file
6#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
7pub struct ParsedMigration {
8    pub path: PathBuf,
9    pub version: u64,
10    pub description: String,
11}
12
13/// Represents a parsed baseline file
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ParsedBaseline {
16    pub path: PathBuf,
17    pub version: u64,
18}
19
20/// Parse a migration filename like "V1734567890_add_user_index.sql" or "1734567890_add_user_index.sql"
21/// Accepts files with or without the V prefix for backwards compatibility.
22pub fn parse_migration_filename(filename: &str) -> Option<(u64, String)> {
23    if !filename.ends_with(".sql") {
24        return None;
25    }
26
27    let name_without_ext = &filename[..filename.len() - 4]; // Remove ".sql"
28
29    // Strip optional V prefix
30    let name_without_prefix = name_without_ext
31        .strip_prefix('V')
32        .unwrap_or(name_without_ext);
33
34    let parts: Vec<&str> = name_without_prefix.splitn(2, '_').collect();
35
36    if parts.len() != 2 {
37        return None;
38    }
39
40    let version = parts[0].parse::<u64>().ok()?;
41    let description = parts[1].to_string();
42
43    Some((version, description))
44}
45
46/// Parse a baseline filename like "baseline_1734567890.sql" or "baseline_V1734567890.sql"
47/// Accepts files with or without the V prefix for backwards compatibility.
48pub fn parse_baseline_filename(filename: &str) -> Option<u64> {
49    if !filename.starts_with(BASELINE_FILENAME_PREFIX) || !filename.ends_with(".sql") {
50        return None;
51    }
52
53    let version_str = filename
54        .strip_prefix(BASELINE_FILENAME_PREFIX)?
55        .strip_suffix(".sql")?;
56
57    // Strip optional V prefix for backwards compatibility with old baseline files
58    let version_str = version_str.strip_prefix('V').unwrap_or(version_str);
59
60    version_str.parse::<u64>().ok()
61}
62
63/// Generate a baseline filename from version
64pub fn generate_baseline_filename(version: u64) -> String {
65    format!("{}{}.sql", BASELINE_FILENAME_PREFIX, version)
66}
67
68/// Find all migration files in a directory and return them sorted by version
69pub fn discover_migrations(migrations_dir: &Path) -> Result<Vec<ParsedMigration>> {
70    let mut migrations = Vec::new();
71
72    if !migrations_dir.exists() {
73        return Ok(migrations);
74    }
75
76    for entry in std::fs::read_dir(migrations_dir)? {
77        let entry = entry?;
78        let path = entry.path();
79
80        if path.extension().is_some_and(|ext| ext == "sql")
81            && let Some(filename) = path.file_name().and_then(|n| n.to_str())
82            && let Some((version, description)) = parse_migration_filename(filename)
83        {
84            migrations.push(ParsedMigration {
85                path,
86                version,
87                description,
88            });
89        }
90    }
91
92    // Sort by version (chronological order)
93    migrations.sort_by_key(|m| m.version);
94
95    Ok(migrations)
96}
97
98/// Find all baseline files in a directory and return them sorted by version
99pub fn discover_baselines(baselines_dir: &Path) -> Result<Vec<ParsedBaseline>> {
100    let mut baselines = Vec::new();
101
102    if !baselines_dir.exists() {
103        return Ok(baselines);
104    }
105
106    for entry in std::fs::read_dir(baselines_dir)? {
107        let entry = entry?;
108        let path = entry.path();
109
110        if path.extension().is_some_and(|ext| ext == "sql")
111            && let Some(filename) = path.file_name().and_then(|n| n.to_str())
112            && let Some(version) = parse_baseline_filename(filename)
113        {
114            baselines.push(ParsedBaseline { path, version });
115        }
116    }
117
118    // Sort by version (chronological order)
119    baselines.sort_by_key(|b| b.version);
120
121    Ok(baselines)
122}
123
124/// Find the latest migration file in a directory
125pub fn find_latest_migration(migrations_dir: &Path) -> Result<Option<ParsedMigration>> {
126    let migrations = discover_migrations(migrations_dir)?;
127    Ok(migrations.last().cloned())
128}
129
130/// Find the latest baseline file in a directory
131pub fn find_latest_baseline(baselines_dir: &Path) -> Result<Option<ParsedBaseline>> {
132    let baselines = discover_baselines(baselines_dir)?;
133    Ok(baselines.last().cloned())
134}
135
136/// Find the baseline that should be used for a specific migration version
137/// (i.e., the latest baseline that has a version less than the target version)
138pub fn find_baseline_for_version(
139    baselines_dir: &Path,
140    target_version: u64,
141) -> Result<Option<ParsedBaseline>> {
142    let baselines = discover_baselines(baselines_dir)?;
143
144    // Find the latest baseline that is less than the target version
145    let previous_baseline = baselines
146        .iter()
147        .rev()
148        .find(|b| b.version < target_version)
149        .cloned();
150
151    Ok(previous_baseline)
152}
153
154/// Find a migration by version string (supports full and partial matches)
155pub fn find_migration_by_version(
156    migrations_dir: &Path,
157    version_str: &str,
158) -> Result<Option<ParsedMigration>> {
159    let migrations = discover_migrations(migrations_dir)?;
160
161    // Remove 'V' prefix if present
162    let version_str = version_str.strip_prefix("V").unwrap_or(version_str);
163
164    // Try exact version match first
165    if let Ok(exact_version) = version_str.parse::<u64>()
166        && let Some(migration) = migrations.iter().find(|m| m.version == exact_version)
167    {
168        return Ok(Some(migration.clone()));
169    }
170
171    // Try partial match (find migrations that start with the given string)
172    let matching_migrations: Vec<_> = migrations
173        .iter()
174        .filter(|m| m.version.to_string().starts_with(version_str))
175        .collect();
176
177    match matching_migrations.len() {
178        0 => Ok(None),
179        1 => Ok(Some(matching_migrations[0].clone())),
180        _ => {
181            // Multiple matches - return an error with suggestions
182            let versions: Vec<String> = matching_migrations
183                .iter()
184                .map(|m| m.version.to_string())
185                .collect();
186            Err(anyhow::anyhow!(
187                "Ambiguous migration version '{}'. Matches: {}. Please be more specific.",
188                version_str,
189                versions.join(", ")
190            ))
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use std::env;
199
200    #[test]
201    fn test_parse_migration_filename() {
202        // Valid migration filenames with V prefix (backwards compat)
203        assert_eq!(
204            parse_migration_filename("V1734567890_add_user_index.sql"),
205            Some((1734567890, "add_user_index".to_string()))
206        );
207
208        assert_eq!(
209            parse_migration_filename("V1234567890_create_tables.sql"),
210            Some((1234567890, "create_tables".to_string()))
211        );
212
213        // Valid migration filenames without prefix (new default)
214        assert_eq!(
215            parse_migration_filename("1734567890_add_user_index.sql"),
216            Some((1734567890, "add_user_index".to_string()))
217        );
218
219        assert_eq!(
220            parse_migration_filename("1234567890_create_tables.sql"),
221            Some((1234567890, "create_tables".to_string()))
222        );
223
224        // Invalid migration filenames
225        assert_eq!(parse_migration_filename("V1734567890_add_user_index"), None); // Missing .sql suffix
226        assert_eq!(parse_migration_filename("V1734567890.sql"), None); // Missing description
227        assert_eq!(parse_migration_filename("1734567890.sql"), None); // Missing description (no prefix)
228        assert_eq!(parse_migration_filename("Vabc_description.sql"), None); // Invalid version
229        assert_eq!(parse_migration_filename("abc_description.sql"), None); // Invalid version (no prefix)
230        assert_eq!(parse_migration_filename("baseline_V1234567890.sql"), None); // Baseline, not migration
231    }
232
233    #[test]
234    fn test_parse_baseline_filename() {
235        // Valid baseline filenames (new format, no V prefix)
236        assert_eq!(
237            parse_baseline_filename("baseline_1734567890.sql"),
238            Some(1734567890)
239        );
240        assert_eq!(
241            parse_baseline_filename("baseline_1234567890.sql"),
242            Some(1234567890)
243        );
244
245        // Valid baseline filenames (old format with V prefix, backwards compat)
246        assert_eq!(
247            parse_baseline_filename("baseline_V1734567890.sql"),
248            Some(1734567890)
249        );
250        assert_eq!(
251            parse_baseline_filename("baseline_V1234567890.sql"),
252            Some(1234567890)
253        );
254
255        // Invalid baseline filenames
256        assert_eq!(parse_baseline_filename("V1734567890_description.sql"), None); // Migration, not baseline
257        assert_eq!(parse_baseline_filename("baseline_V1734567890"), None); // Missing .sql suffix
258        assert_eq!(parse_baseline_filename("baseline_Vabc.sql"), None); // Invalid version
259    }
260
261    #[test]
262    fn test_generate_filenames() {
263        assert_eq!(
264            generate_baseline_filename(1734567890),
265            "baseline_1734567890.sql"
266        );
267    }
268
269    #[test]
270    fn test_discover_migrations() {
271        let temp_dir = env::temp_dir().join("pgmt_test_discover_migrations");
272        let _ = std::fs::remove_dir_all(&temp_dir);
273        std::fs::create_dir_all(&temp_dir).unwrap();
274
275        // Create test migration files - mix of V-prefixed and unprefixed
276        std::fs::write(
277            temp_dir.join("V1000000000_first_migration.sql"),
278            "-- First migration (V prefix)",
279        )
280        .unwrap();
281        std::fs::write(
282            temp_dir.join("2000000000_second_migration.sql"),
283            "-- Second migration (no prefix)",
284        )
285        .unwrap();
286        std::fs::write(
287            temp_dir.join("V3000000000_third_migration.sql"),
288            "-- Third migration (V prefix)",
289        )
290        .unwrap();
291        std::fs::write(temp_dir.join("invalid_file.sql"), "-- Invalid").unwrap();
292        std::fs::write(temp_dir.join("readme.txt"), "-- Not SQL").unwrap();
293
294        let migrations = discover_migrations(&temp_dir).unwrap();
295
296        assert_eq!(migrations.len(), 3);
297        assert_eq!(migrations[0].version, 1000000000);
298        assert_eq!(migrations[0].description, "first_migration");
299        assert_eq!(migrations[1].version, 2000000000);
300        assert_eq!(migrations[1].description, "second_migration");
301        assert_eq!(migrations[2].version, 3000000000);
302        assert_eq!(migrations[2].description, "third_migration");
303
304        // Cleanup
305        let _ = std::fs::remove_dir_all(&temp_dir);
306    }
307
308    #[test]
309    fn test_discover_baselines() {
310        let temp_dir = env::temp_dir().join("pgmt_test_discover_baselines");
311        let _ = std::fs::remove_dir_all(&temp_dir);
312        std::fs::create_dir_all(&temp_dir).unwrap();
313
314        // Create test baseline files (mix of old V-prefix and new format)
315        std::fs::write(
316            temp_dir.join("baseline_V1000000000.sql"),
317            "-- First baseline (old format)",
318        )
319        .unwrap();
320        std::fs::write(
321            temp_dir.join("baseline_2000000000.sql"),
322            "-- Second baseline (new format)",
323        )
324        .unwrap();
325        std::fs::write(
326            temp_dir.join("V1000000000_migration.sql"),
327            "-- Migration file",
328        )
329        .unwrap();
330        std::fs::write(temp_dir.join("readme.txt"), "-- Not SQL").unwrap();
331
332        let baselines = discover_baselines(&temp_dir).unwrap();
333
334        assert_eq!(baselines.len(), 2);
335        assert_eq!(baselines[0].version, 1000000000);
336        assert_eq!(baselines[1].version, 2000000000);
337
338        // Cleanup
339        let _ = std::fs::remove_dir_all(&temp_dir);
340    }
341
342    #[test]
343    fn test_find_latest_migration() {
344        let temp_dir = env::temp_dir().join("pgmt_test_find_latest_migration");
345        let _ = std::fs::remove_dir_all(&temp_dir);
346        std::fs::create_dir_all(&temp_dir).unwrap();
347
348        // Empty directory should return None
349        assert!(find_latest_migration(&temp_dir).unwrap().is_none());
350
351        // Create test migration files
352        std::fs::write(temp_dir.join("V1000000000_first_migration.sql"), "-- First").unwrap();
353        std::fs::write(temp_dir.join("V3000000000_third_migration.sql"), "-- Third").unwrap();
354        std::fs::write(
355            temp_dir.join("V2000000000_second_migration.sql"),
356            "-- Second",
357        )
358        .unwrap();
359
360        let latest = find_latest_migration(&temp_dir).unwrap().unwrap();
361        assert_eq!(latest.version, 3000000000);
362        assert_eq!(latest.description, "third_migration");
363
364        // Cleanup
365        let _ = std::fs::remove_dir_all(&temp_dir);
366    }
367
368    #[test]
369    fn test_find_baseline_for_version() {
370        let temp_dir = env::temp_dir().join("pgmt_test_find_baseline_for_version");
371        let _ = std::fs::remove_dir_all(&temp_dir);
372        std::fs::create_dir_all(&temp_dir).unwrap();
373
374        // Create test baseline files
375        std::fs::write(temp_dir.join("baseline_V1000000000.sql"), "-- Baseline 1").unwrap();
376        std::fs::write(temp_dir.join("baseline_V2000000000.sql"), "-- Baseline 2").unwrap();
377        std::fs::write(temp_dir.join("baseline_V4000000000.sql"), "-- Baseline 4").unwrap();
378
379        // Find baseline for version 3000000000 (should get baseline_V2000000000)
380        let baseline = find_baseline_for_version(&temp_dir, 3000000000)
381            .unwrap()
382            .unwrap();
383        assert_eq!(baseline.version, 2000000000);
384
385        // Find baseline for version 1500000000 (should get baseline_V1000000000)
386        let baseline = find_baseline_for_version(&temp_dir, 1500000000)
387            .unwrap()
388            .unwrap();
389        assert_eq!(baseline.version, 1000000000);
390
391        // Find baseline for version 500000000 (should get None - no baseline before this)
392        assert!(
393            find_baseline_for_version(&temp_dir, 500000000)
394                .unwrap()
395                .is_none()
396        );
397
398        // Cleanup
399        let _ = std::fs::remove_dir_all(&temp_dir);
400    }
401}