Skip to main content

waypoint_core/
migration.rs

1//! Migration file parsing, scanning, and types.
2//!
3//! Supports versioned (`V{version}__{desc}.sql`) and repeatable (`R__{desc}.sql`) migrations.
4
5use std::cmp::Ordering;
6use std::fmt;
7use std::sync::LazyLock;
8
9use regex_lite::Regex;
10
11use crate::checksum::calculate_checksum;
12use crate::directive::{self, MigrationDirectives};
13use crate::error::{Result, WaypointError};
14use crate::hooks;
15
16static VERSIONED_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^V([\d._]+)__(.+)$").unwrap());
17static UNDO_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^U([\d._]+)__(.+)$").unwrap());
18static REPEATABLE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^R__(.+)$").unwrap());
19
20/// A parsed migration version, supporting dotted numeric segments (e.g., "1.2.3").
21///
22/// # Equality
23///
24/// Two versions are equal when their numeric segments are equal after trailing
25/// zeros are dropped: `1`, `1.0` and `1.0.0` are all the same version. The
26/// `raw` string is *not* part of the identity.
27///
28/// This has to match [`Ord`], which compares zero-padded segment-wise. A
29/// derived `PartialEq` would compare `raw` too, so `1` and `1.0` would report
30/// `Ordering::Equal` from `cmp` while `==` said `false` — a violation of the
31/// `Ord`/`Eq` contract that silently corrupts `BTreeMap`, `binary_search` and
32/// `dedup`.
33#[derive(Debug, Clone)]
34pub struct MigrationVersion {
35    /// Parsed numeric segments of the version (e.g., `[1, 2, 3]` for `"1.2.3"`).
36    pub segments: Vec<u64>,
37    /// Original version string as it appeared in the filename.
38    pub raw: String,
39}
40
41impl MigrationVersion {
42    /// The segments with trailing zeros removed — the canonical identity used
43    /// by [`PartialEq`], [`Ord`] and [`std::hash::Hash`].
44    ///
45    /// `1.0.0` normalizes to `[1]`, `1.2.0` to `[1, 2]`, and `0` / `0.0` to the
46    /// empty slice (all-zero versions are equal to each other).
47    pub fn normalized(&self) -> &[u64] {
48        let end = self
49            .segments
50            .iter()
51            .rposition(|&s| s != 0)
52            .map_or(0, |i| i + 1);
53        &self.segments[..end]
54    }
55
56    /// Parse a version string like `"1.2.3"` or `"1_2"` into segments.
57    pub fn parse(raw: &str) -> Result<Self> {
58        if raw.is_empty() {
59            return Err(WaypointError::MigrationParseError(
60                "Version string is empty".to_string(),
61            ));
62        }
63
64        // Support both "." and "_" as segment separators
65        let segments: std::result::Result<Vec<u64>, _> =
66            raw.split(['.', '_']).map(|s| s.parse::<u64>()).collect();
67
68        let segments = segments.map_err(|e| {
69            WaypointError::MigrationParseError(format!(
70                "Invalid version segment in '{}': {}",
71                raw, e
72            ))
73        })?;
74
75        Ok(MigrationVersion {
76            segments,
77            raw: raw.to_string(),
78        })
79    }
80}
81
82impl Ord for MigrationVersion {
83    fn cmp(&self, other: &Self) -> Ordering {
84        // Zero-pad the shorter side so `1.2` and `1.2.0` compare equal.
85        self.normalized().cmp(other.normalized())
86    }
87}
88
89impl PartialOrd for MigrationVersion {
90    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
91        Some(self.cmp(other))
92    }
93}
94
95impl PartialEq for MigrationVersion {
96    fn eq(&self, other: &Self) -> bool {
97        self.normalized() == other.normalized()
98    }
99}
100
101impl Eq for MigrationVersion {}
102
103impl std::hash::Hash for MigrationVersion {
104    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
105        self.normalized().hash(state);
106    }
107}
108
109impl fmt::Display for MigrationVersion {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(f, "{}", self.raw)
112    }
113}
114
115/// The type of a migration (for display/serialization).
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum MigrationType {
118    /// V{version}__{description}.sql
119    Versioned,
120    /// R__{description}.sql
121    Repeatable,
122    /// U{version}__{description}.sql
123    Undo,
124}
125
126impl fmt::Display for MigrationType {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            MigrationType::Versioned => write!(f, "SQL"),
130            MigrationType::Repeatable => write!(f, "SQL_REPEATABLE"),
131            MigrationType::Undo => write!(f, "UNDO_SQL"),
132        }
133    }
134}
135
136/// Type-safe encoding of the migration variant.
137///
138/// Versioned migrations always have a version; repeatable migrations never do.
139/// This eliminates the `Option<MigrationVersion>` + `MigrationType` redundancy.
140#[derive(Debug, Clone)]
141pub enum MigrationKind {
142    /// A versioned migration with an associated version number.
143    Versioned(MigrationVersion),
144    /// A repeatable migration that is re-applied whenever its checksum changes.
145    Repeatable,
146    /// An undo migration that reverses a specific versioned migration.
147    Undo(MigrationVersion),
148}
149
150/// A migration file discovered on disk.
151#[derive(Debug, Clone)]
152pub struct ResolvedMigration {
153    /// Whether this is a versioned, repeatable, or undo migration (with version if applicable).
154    pub kind: MigrationKind,
155    /// Human-readable description extracted from the filename.
156    pub description: String,
157    /// Original filename of the migration script (e.g., `V1__Create_users.sql`).
158    pub script: String,
159    /// CRC32 checksum of the migration SQL content.
160    pub checksum: i32,
161    /// Raw SQL content of the migration file.
162    pub sql: String,
163    /// Parsed directives from SQL comments (e.g., `@depends`, `@environment`).
164    pub directives: MigrationDirectives,
165}
166
167impl ResolvedMigration {
168    /// Get the version if this is a versioned or undo migration.
169    pub fn version(&self) -> Option<&MigrationVersion> {
170        match &self.kind {
171            MigrationKind::Versioned(v) | MigrationKind::Undo(v) => Some(v),
172            MigrationKind::Repeatable => None,
173        }
174    }
175
176    /// Get the migration type for display/serialization.
177    pub fn migration_type(&self) -> MigrationType {
178        match &self.kind {
179            MigrationKind::Versioned(_) => MigrationType::Versioned,
180            MigrationKind::Repeatable => MigrationType::Repeatable,
181            MigrationKind::Undo(_) => MigrationType::Undo,
182        }
183    }
184
185    /// Whether this is a versioned migration.
186    pub fn is_versioned(&self) -> bool {
187        matches!(&self.kind, MigrationKind::Versioned(_))
188    }
189
190    /// Whether this is an undo migration.
191    pub fn is_undo(&self) -> bool {
192        matches!(&self.kind, MigrationKind::Undo(_))
193    }
194}
195
196/// Parse a migration filename into its components.
197///
198/// Expected patterns:
199///   V{version}__{description}.sql  — versioned migration
200///   R__{description}.sql           — repeatable migration
201pub fn parse_migration_filename(filename: &str) -> Result<(MigrationKind, String)> {
202    // Strip .sql extension
203    let stem = filename.strip_suffix(".sql").ok_or_else(|| {
204        WaypointError::MigrationParseError(format!(
205            "Migration file '{}' does not have .sql extension",
206            filename
207        ))
208    })?;
209
210    if let Some(caps) = VERSIONED_RE.captures(stem) {
211        let version_str = caps.get(1).unwrap().as_str();
212        let description = caps.get(2).unwrap().as_str().replace('_', " ");
213        let version = MigrationVersion::parse(version_str)?;
214        Ok((MigrationKind::Versioned(version), description))
215    } else if let Some(caps) = UNDO_RE.captures(stem) {
216        let version_str = caps.get(1).unwrap().as_str();
217        let description = caps.get(2).unwrap().as_str().replace('_', " ");
218        let version = MigrationVersion::parse(version_str)?;
219        Ok((MigrationKind::Undo(version), description))
220    } else if let Some(caps) = REPEATABLE_RE.captures(stem) {
221        let description = caps.get(1).unwrap().as_str().replace('_', " ");
222        Ok((MigrationKind::Repeatable, description))
223    } else {
224        Err(WaypointError::MigrationParseError(format!(
225            "Migration file '{}' does not match V{{version}}__{{description}}.sql, U{{version}}__{{description}}.sql, or R__{{description}}.sql pattern",
226            filename
227        )))
228    }
229}
230
231/// Scan migration locations for SQL files and parse them into ResolvedMigrations.
232pub fn scan_migrations(locations: &[std::path::PathBuf]) -> Result<Vec<ResolvedMigration>> {
233    let mut migrations = Vec::new();
234
235    for location in locations {
236        if !location.exists() {
237            log::warn!("Migration location does not exist: {}", location.display());
238            continue;
239        }
240
241        let entries = std::fs::read_dir(location).map_err(|e| {
242            WaypointError::IoError(std::io::Error::new(
243                e.kind(),
244                format!(
245                    "Failed to read migration directory '{}': {}",
246                    location.display(),
247                    e
248                ),
249            ))
250        })?;
251
252        for entry in entries {
253            let entry = entry?;
254            let path = entry.path();
255
256            if !path.is_file() {
257                continue;
258            }
259
260            let filename = match path.file_name().and_then(|n| n.to_str()) {
261                Some(name) => name.to_string(),
262                None => continue,
263            };
264
265            // Skip non-SQL files
266            if !filename.ends_with(".sql") {
267                continue;
268            }
269
270            // Skip hook callback files
271            if hooks::is_hook_file(&filename) {
272                continue;
273            }
274
275            // Skip files that don't start with V, U, or R
276            if !filename.starts_with('V')
277                && !filename.starts_with('U')
278                && !filename.starts_with('R')
279            {
280                continue;
281            }
282
283            let (kind, description) = match parse_migration_filename(&filename) {
284                Ok(result) => result,
285                Err(e) => {
286                    log::warn!("Skipping malformed migration file '{}': {}", filename, e);
287                    continue;
288                }
289            };
290            let sql = std::fs::read_to_string(&path)?;
291            let checksum = calculate_checksum(&sql);
292            let directives = directive::parse_directives(&sql);
293
294            migrations.push(ResolvedMigration {
295                kind,
296                description,
297                script: filename,
298                checksum,
299                sql,
300                directives,
301            });
302        }
303    }
304
305    // Sort: versioned by version, then undo by version, then repeatable by description
306    migrations.sort_by(|a, b| {
307        // Order groups: Versioned first, then Undo, then Repeatable
308        fn group_order(kind: &MigrationKind) -> u8 {
309            match kind {
310                MigrationKind::Versioned(_) => 0,
311                MigrationKind::Undo(_) => 1,
312                MigrationKind::Repeatable => 2,
313            }
314        }
315        let ga = group_order(&a.kind);
316        let gb = group_order(&b.kind);
317        if ga != gb {
318            return ga.cmp(&gb);
319        }
320        match (&a.kind, &b.kind) {
321            (MigrationKind::Versioned(va), MigrationKind::Versioned(vb)) => va.cmp(vb),
322            (MigrationKind::Undo(va), MigrationKind::Undo(vb)) => va.cmp(vb),
323            (MigrationKind::Repeatable, MigrationKind::Repeatable) => {
324                a.description.cmp(&b.description)
325            }
326            _ => Ordering::Equal,
327        }
328    });
329
330    // Detect duplicate versions. Keyed on the *normalized* segments, not the
331    // raw string, so `V1__a.sql` and `V1.0__b.sql` are caught — they order as
332    // the same version, so allowing both would apply two migrations that every
333    // ordering comparison treats as one.
334    let mut seen_versions: std::collections::HashMap<(bool, Vec<u64>), &str> =
335        std::collections::HashMap::new();
336    for m in &migrations {
337        if let Some(v) = m.version() {
338            let key = (m.is_versioned(), v.normalized().to_vec());
339            if let Some(previous) = seen_versions.insert(key, m.script.as_str()) {
340                return Err(WaypointError::ValidationFailed(format!(
341                    "Duplicate migration version '{}' found in files '{}' and '{}'. \
342                     Each version must be unique (note that '1', '1.0' and '1.0.0' \
343                     are the same version).",
344                    v.raw, previous, m.script
345                )));
346            }
347        }
348    }
349
350    Ok(migrations)
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn test_version_parsing() {
359        let v = MigrationVersion::parse("1").unwrap();
360        assert_eq!(v.segments, vec![1]);
361
362        let v = MigrationVersion::parse("1.2.3").unwrap();
363        assert_eq!(v.segments, vec![1, 2, 3]);
364
365        let v = MigrationVersion::parse("1_2_3").unwrap();
366        assert_eq!(v.segments, vec![1, 2, 3]);
367    }
368
369    #[test]
370    fn test_version_ordering() {
371        let v1 = MigrationVersion::parse("1").unwrap();
372        let v2 = MigrationVersion::parse("2").unwrap();
373        let v1_9 = MigrationVersion::parse("1.9").unwrap();
374        let v1_10 = MigrationVersion::parse("1.10").unwrap();
375        let v1_2 = MigrationVersion::parse("1.2").unwrap();
376        let v1_2_0 = MigrationVersion::parse("1.2.0").unwrap();
377
378        assert!(v1 < v2);
379        assert!(v1_9 < v1_10); // Numeric, not string comparison
380        assert!(v1_2 < v1_9);
381        assert_eq!(v1_2.cmp(&v1_2_0), Ordering::Equal); // Trailing zeros are equal
382    }
383
384    #[test]
385    fn test_version_eq_matches_ord() {
386        // The Ord/Eq contract: cmp == Equal must imply ==.
387        let cases = [("1", "1.0"), ("1.2", "1.2.0"), ("1", "1.0.0"), ("0", "0.0")];
388        for (a, b) in cases {
389            let va = MigrationVersion::parse(a).unwrap();
390            let vb = MigrationVersion::parse(b).unwrap();
391            assert_eq!(va.cmp(&vb), Ordering::Equal, "{a} vs {b}");
392            assert_eq!(va, vb, "{a} vs {b} should be equal");
393        }
394
395        let v1 = MigrationVersion::parse("1").unwrap();
396        let v2 = MigrationVersion::parse("2").unwrap();
397        assert_ne!(v1, v2);
398        assert_ne!(v1.cmp(&v2), Ordering::Equal);
399    }
400
401    #[test]
402    fn test_version_hash_matches_eq() {
403        use std::collections::HashSet;
404        let mut set = HashSet::new();
405        set.insert(MigrationVersion::parse("1.0").unwrap());
406        // Equal values must hash equal, so this is a duplicate insert.
407        assert!(!set.insert(MigrationVersion::parse("1").unwrap()));
408        assert!(set.insert(MigrationVersion::parse("1.1").unwrap()));
409        assert_eq!(set.len(), 2);
410    }
411
412    #[test]
413    fn test_version_normalized() {
414        assert_eq!(MigrationVersion::parse("1.0.0").unwrap().normalized(), &[1]);
415        assert_eq!(
416            MigrationVersion::parse("1.2.0").unwrap().normalized(),
417            &[1, 2]
418        );
419        assert!(
420            MigrationVersion::parse("0.0")
421                .unwrap()
422                .normalized()
423                .is_empty()
424        );
425        assert_eq!(
426            MigrationVersion::parse("1.0.3").unwrap().normalized(),
427            &[1, 0, 3]
428        );
429    }
430
431    #[test]
432    fn test_version_parse_error() {
433        assert!(MigrationVersion::parse("").is_err());
434        assert!(MigrationVersion::parse("abc").is_err());
435    }
436
437    #[test]
438    fn test_parse_versioned_filename() {
439        let (kind, desc) = parse_migration_filename("V1__Create_users.sql").unwrap();
440        match kind {
441            MigrationKind::Versioned(v) => assert_eq!(v.segments, vec![1]),
442            _ => panic!("Expected Versioned"),
443        }
444        assert_eq!(desc, "Create users");
445    }
446
447    #[test]
448    fn test_parse_versioned_dotted_version() {
449        let (kind, desc) = parse_migration_filename("V1.2.3__Add_column.sql").unwrap();
450        match kind {
451            MigrationKind::Versioned(v) => assert_eq!(v.segments, vec![1, 2, 3]),
452            _ => panic!("Expected Versioned"),
453        }
454        assert_eq!(desc, "Add column");
455    }
456
457    #[test]
458    fn test_parse_repeatable_filename() {
459        let (kind, desc) = parse_migration_filename("R__Create_user_view.sql").unwrap();
460        assert!(matches!(kind, MigrationKind::Repeatable));
461        assert_eq!(desc, "Create user view");
462    }
463
464    #[test]
465    fn test_parse_invalid_filename() {
466        assert!(parse_migration_filename("random.sql").is_err());
467        assert!(parse_migration_filename("V1_missing_separator.sql").is_err());
468        assert!(parse_migration_filename("V1__no_ext").is_err());
469    }
470
471    #[test]
472    fn test_parse_undo_filename() {
473        let (kind, desc) = parse_migration_filename("U1__Create_users.sql").unwrap();
474        match kind {
475            MigrationKind::Undo(v) => assert_eq!(v.segments, vec![1]),
476            _ => panic!("Expected Undo"),
477        }
478        assert_eq!(desc, "Create users");
479    }
480
481    #[test]
482    fn test_parse_undo_dotted_version() {
483        let (kind, desc) = parse_migration_filename("U1.2.3__Add_column.sql").unwrap();
484        match kind {
485            MigrationKind::Undo(v) => assert_eq!(v.segments, vec![1, 2, 3]),
486            _ => panic!("Expected Undo"),
487        }
488        assert_eq!(desc, "Add column");
489    }
490
491    #[test]
492    fn test_malformed_filename_is_skipped() {
493        // This tests the parse function itself
494        assert!(parse_migration_filename("random.sql").is_err());
495        assert!(parse_migration_filename("V1_missing_separator.sql").is_err());
496    }
497
498    #[test]
499    fn test_undo_is_undo() {
500        let m = ResolvedMigration {
501            kind: MigrationKind::Undo(MigrationVersion::parse("1").unwrap()),
502            description: "test".to_string(),
503            script: "U1__test.sql".to_string(),
504            checksum: 0,
505            sql: String::new(),
506            directives: MigrationDirectives::default(),
507        };
508        assert!(m.is_undo());
509        assert!(!m.is_versioned());
510        assert_eq!(m.migration_type(), MigrationType::Undo);
511        assert_eq!(m.migration_type().to_string(), "UNDO_SQL");
512    }
513}