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            // A `.sql` file that is neither a hook nor a `V`/`U`/`R` migration
276            // is skipped — but say so. This used to be a bare `continue`, so
277            // `v1__create_users.sql` with a lowercase `v` (an easy mistake, and
278            // invisible on a case-insensitive filesystem) was never applied and
279            // nothing anywhere mentioned it. The malformed-name branch below
280            // already warns; these two must agree.
281            if !filename.starts_with('V')
282                && !filename.starts_with('U')
283                && !filename.starts_with('R')
284            {
285                log::warn!(
286                    "Ignoring '{}': migration filenames must start with V (versioned), \
287                     U (undo) or R (repeatable), and the prefix is case-sensitive.",
288                    filename
289                );
290                continue;
291            }
292
293            let (kind, description) = match parse_migration_filename(&filename) {
294                Ok(result) => result,
295                Err(e) => {
296                    log::warn!("Skipping malformed migration file '{}': {}", filename, e);
297                    continue;
298                }
299            };
300            let sql = std::fs::read_to_string(&path)?;
301            let checksum = calculate_checksum(&sql);
302            let directives = directive::parse_directives(&sql);
303
304            migrations.push(ResolvedMigration {
305                kind,
306                description,
307                script: filename,
308                checksum,
309                sql,
310                directives,
311            });
312        }
313    }
314
315    // Sort: versioned by version, then undo by version, then repeatable by description
316    migrations.sort_by(|a, b| {
317        // Order groups: Versioned first, then Undo, then Repeatable
318        fn group_order(kind: &MigrationKind) -> u8 {
319            match kind {
320                MigrationKind::Versioned(_) => 0,
321                MigrationKind::Undo(_) => 1,
322                MigrationKind::Repeatable => 2,
323            }
324        }
325        let ga = group_order(&a.kind);
326        let gb = group_order(&b.kind);
327        if ga != gb {
328            return ga.cmp(&gb);
329        }
330        match (&a.kind, &b.kind) {
331            (MigrationKind::Versioned(va), MigrationKind::Versioned(vb)) => va.cmp(vb),
332            (MigrationKind::Undo(va), MigrationKind::Undo(vb)) => va.cmp(vb),
333            (MigrationKind::Repeatable, MigrationKind::Repeatable) => {
334                a.description.cmp(&b.description)
335            }
336            _ => Ordering::Equal,
337        }
338    });
339
340    // Detect duplicate versions. Keyed on the *normalized* segments, not the
341    // raw string, so `V1__a.sql` and `V1.0__b.sql` are caught — they order as
342    // the same version, so allowing both would apply two migrations that every
343    // ordering comparison treats as one.
344    let mut seen_versions: std::collections::HashMap<(bool, Vec<u64>), &str> =
345        std::collections::HashMap::new();
346    for m in &migrations {
347        if let Some(v) = m.version() {
348            let key = (m.is_versioned(), v.normalized().to_vec());
349            if let Some(previous) = seen_versions.insert(key, m.script.as_str()) {
350                return Err(WaypointError::ValidationFailed(format!(
351                    "Duplicate migration version '{}' found in files '{}' and '{}'. \
352                     Each version must be unique (note that '1', '1.0' and '1.0.0' \
353                     are the same version).",
354                    v.raw, previous, m.script
355                )));
356            }
357        }
358    }
359
360    Ok(migrations)
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn test_version_parsing() {
369        let v = MigrationVersion::parse("1").unwrap();
370        assert_eq!(v.segments, vec![1]);
371
372        let v = MigrationVersion::parse("1.2.3").unwrap();
373        assert_eq!(v.segments, vec![1, 2, 3]);
374
375        let v = MigrationVersion::parse("1_2_3").unwrap();
376        assert_eq!(v.segments, vec![1, 2, 3]);
377    }
378
379    #[test]
380    fn test_version_ordering() {
381        let v1 = MigrationVersion::parse("1").unwrap();
382        let v2 = MigrationVersion::parse("2").unwrap();
383        let v1_9 = MigrationVersion::parse("1.9").unwrap();
384        let v1_10 = MigrationVersion::parse("1.10").unwrap();
385        let v1_2 = MigrationVersion::parse("1.2").unwrap();
386        let v1_2_0 = MigrationVersion::parse("1.2.0").unwrap();
387
388        assert!(v1 < v2);
389        assert!(v1_9 < v1_10); // Numeric, not string comparison
390        assert!(v1_2 < v1_9);
391        assert_eq!(v1_2.cmp(&v1_2_0), Ordering::Equal); // Trailing zeros are equal
392    }
393
394    #[test]
395    fn test_version_eq_matches_ord() {
396        // The Ord/Eq contract: cmp == Equal must imply ==.
397        let cases = [("1", "1.0"), ("1.2", "1.2.0"), ("1", "1.0.0"), ("0", "0.0")];
398        for (a, b) in cases {
399            let va = MigrationVersion::parse(a).unwrap();
400            let vb = MigrationVersion::parse(b).unwrap();
401            assert_eq!(va.cmp(&vb), Ordering::Equal, "{a} vs {b}");
402            assert_eq!(va, vb, "{a} vs {b} should be equal");
403        }
404
405        let v1 = MigrationVersion::parse("1").unwrap();
406        let v2 = MigrationVersion::parse("2").unwrap();
407        assert_ne!(v1, v2);
408        assert_ne!(v1.cmp(&v2), Ordering::Equal);
409    }
410
411    #[test]
412    fn test_version_hash_matches_eq() {
413        use std::collections::HashSet;
414        let mut set = HashSet::new();
415        set.insert(MigrationVersion::parse("1.0").unwrap());
416        // Equal values must hash equal, so this is a duplicate insert.
417        assert!(!set.insert(MigrationVersion::parse("1").unwrap()));
418        assert!(set.insert(MigrationVersion::parse("1.1").unwrap()));
419        assert_eq!(set.len(), 2);
420    }
421
422    #[test]
423    fn test_version_normalized() {
424        assert_eq!(MigrationVersion::parse("1.0.0").unwrap().normalized(), &[1]);
425        assert_eq!(
426            MigrationVersion::parse("1.2.0").unwrap().normalized(),
427            &[1, 2]
428        );
429        assert!(
430            MigrationVersion::parse("0.0")
431                .unwrap()
432                .normalized()
433                .is_empty()
434        );
435        assert_eq!(
436            MigrationVersion::parse("1.0.3").unwrap().normalized(),
437            &[1, 0, 3]
438        );
439    }
440
441    #[test]
442    fn test_version_parse_error() {
443        assert!(MigrationVersion::parse("").is_err());
444        assert!(MigrationVersion::parse("abc").is_err());
445    }
446
447    #[test]
448    fn test_parse_versioned_filename() {
449        let (kind, desc) = parse_migration_filename("V1__Create_users.sql").unwrap();
450        match kind {
451            MigrationKind::Versioned(v) => assert_eq!(v.segments, vec![1]),
452            _ => panic!("Expected Versioned"),
453        }
454        assert_eq!(desc, "Create users");
455    }
456
457    #[test]
458    fn test_parse_versioned_dotted_version() {
459        let (kind, desc) = parse_migration_filename("V1.2.3__Add_column.sql").unwrap();
460        match kind {
461            MigrationKind::Versioned(v) => assert_eq!(v.segments, vec![1, 2, 3]),
462            _ => panic!("Expected Versioned"),
463        }
464        assert_eq!(desc, "Add column");
465    }
466
467    #[test]
468    fn test_parse_repeatable_filename() {
469        let (kind, desc) = parse_migration_filename("R__Create_user_view.sql").unwrap();
470        assert!(matches!(kind, MigrationKind::Repeatable));
471        assert_eq!(desc, "Create user view");
472    }
473
474    #[test]
475    fn test_parse_invalid_filename() {
476        assert!(parse_migration_filename("random.sql").is_err());
477        assert!(parse_migration_filename("V1_missing_separator.sql").is_err());
478        assert!(parse_migration_filename("V1__no_ext").is_err());
479    }
480
481    #[test]
482    fn test_parse_undo_filename() {
483        let (kind, desc) = parse_migration_filename("U1__Create_users.sql").unwrap();
484        match kind {
485            MigrationKind::Undo(v) => assert_eq!(v.segments, vec![1]),
486            _ => panic!("Expected Undo"),
487        }
488        assert_eq!(desc, "Create users");
489    }
490
491    #[test]
492    fn test_parse_undo_dotted_version() {
493        let (kind, desc) = parse_migration_filename("U1.2.3__Add_column.sql").unwrap();
494        match kind {
495            MigrationKind::Undo(v) => assert_eq!(v.segments, vec![1, 2, 3]),
496            _ => panic!("Expected Undo"),
497        }
498        assert_eq!(desc, "Add column");
499    }
500
501    #[test]
502    fn test_malformed_filename_is_skipped() {
503        // This tests the parse function itself
504        assert!(parse_migration_filename("random.sql").is_err());
505        assert!(parse_migration_filename("V1_missing_separator.sql").is_err());
506    }
507
508    #[test]
509    fn test_undo_is_undo() {
510        let m = ResolvedMigration {
511            kind: MigrationKind::Undo(MigrationVersion::parse("1").unwrap()),
512            description: "test".to_string(),
513            script: "U1__test.sql".to_string(),
514            checksum: 0,
515            sql: String::new(),
516            directives: MigrationDirectives::default(),
517        };
518        assert!(m.is_undo());
519        assert!(!m.is_versioned());
520        assert_eq!(m.migration_type(), MigrationType::Undo);
521        assert_eq!(m.migration_type().to_string(), "UNDO_SQL");
522    }
523}