Skip to main content

migrate_guard/
lib.rs

1//! Detect and auto-renumber colliding sqlx migration version numbers across
2//! git branches.
3//!
4//! sqlx assigns a migration's version at author time, so two branches that both
5//! add `NNNN_*.sql` at the same `NNNN` merge cleanly, then fail at test time
6//! with `UNIQUE constraint failed: _sqlx_migrations.version`. `migrate-guard`
7//! catches this before merge and renumbers your topic branch above the global
8//! max. A collision is one version claimed by two *different* files — the same
9//! file seen on multiple branches is not a collision.
10//!
11//! # As a CLI
12//!
13//! The `cargo migrate-guard` subcommand runs at your repo root, configured by a
14//! `migrate-guard.toml` that lists your migration directories:
15//!
16//! ```text
17//! cargo install migrate-guard
18//!
19//! cargo migrate-guard check                 # CI gate: non-zero exit on collision
20//! cargo migrate-guard renumber              # dry run: show the plan
21//! cargo migrate-guard renumber --apply      # renumber this branch + rewrite include_str! refs
22//! cargo migrate-guard max                   # print the max version per role
23//! ```
24//!
25//! ```toml
26//! # migrate-guard.toml
27//! base_ref = "main"
28//! reference_globs = ["src/**/*.rs"]
29//!
30//! [[dir]]
31//! role = "platform"
32//! path = "migrations"
33//! ```
34//!
35//! # As a library
36//!
37//! The pure collision + renumber logic is exposed and needs no git or
38//! filesystem. Parse a filename:
39//!
40//! ```
41//! use migrate_guard::parse_filename;
42//!
43//! assert_eq!(parse_filename("0286_add_index.sql"), Some((286, 4, "add_index")));
44//! assert_eq!(parse_filename("not-a-migration.sql"), None);
45//! ```
46//!
47//! Detect a collision (same version, two different files):
48//!
49//! ```
50//! use migrate_guard::MigrationFile;
51//! use migrate_guard::model::{detect_collisions, Observation, Source};
52//! # use std::path::PathBuf;
53//! # fn file(version: u64, filename: &str) -> MigrationFile {
54//! #     MigrationFile {
55//! #         version, width: 4, description: String::new(),
56//! #         filename: filename.into(), path: PathBuf::from(filename),
57//! #     }
58//! # }
59//! let observations = vec![
60//!     Observation { role: "platform".into(), version: 2,
61//!         source: Source::Branch("feat-a".into()), file: file(2, "0002_a.sql") },
62//!     Observation { role: "platform".into(), version: 2,
63//!         source: Source::WorkingTree, file: file(2, "0002_b.sql") },
64//! ];
65//! let collisions = detect_collisions(&observations);
66//! assert_eq!(collisions.len(), 1);
67//! assert_eq!(collisions[0].version, 2);
68//! ```
69
70use std::path::{Path, PathBuf};
71
72/// One parsed migration file within a numbering namespace.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct MigrationFile {
75    pub version: u64,
76    pub width: usize,
77    pub description: String,
78    pub filename: String,
79    pub path: PathBuf,
80}
81
82/// An independent numbering namespace (platform vs tenant, etc.).
83#[derive(Debug, Clone)]
84pub struct MigrationDir {
85    pub role: String,
86    pub path: PathBuf,
87    pub files: Vec<MigrationFile>,
88}
89
90/// Parse a migration filename: a leading run of ASCII digits, then '_',
91/// then a description, then ".sql". Returns (version, digit_width, desc).
92///
93/// # Examples
94///
95/// ```
96/// use migrate_guard::parse_filename;
97///
98/// assert_eq!(parse_filename("0286_captable.sql"), Some((286, 4, "captable")));
99/// assert_eq!(parse_filename("12_short.sql"), Some((12, 2, "short")));
100/// assert_eq!(parse_filename("no_leading_digits.sql"), None);
101/// assert_eq!(parse_filename("0001_x.txt"), None);
102/// ```
103pub fn parse_filename(name: &str) -> Option<(u64, usize, &str)> {
104    let stem = name.strip_suffix(".sql")?;
105    let digits: String = stem.chars().take_while(|c| c.is_ascii_digit()).collect();
106    if digits.is_empty() {
107        return None;
108    }
109    let rest = &stem[digits.len()..];
110    let desc = rest.strip_prefix('_')?;
111    let version = digits.parse::<u64>().ok()?;
112    Some((version, digits.len(), desc))
113}
114
115/// Scan a directory for migration files (missing dir → empty), sorted by version.
116pub fn scan_dir(role: &str, path: &Path) -> std::io::Result<MigrationDir> {
117    let mut files = Vec::new();
118    if path.exists() {
119        for entry in std::fs::read_dir(path)? {
120            let entry = entry?;
121            let name = entry.file_name().to_string_lossy().into_owned();
122            if let Some((version, width, desc)) = parse_filename(&name) {
123                files.push(MigrationFile {
124                    version,
125                    width,
126                    description: desc.to_string(),
127                    filename: name,
128                    path: entry.path(),
129                });
130            }
131        }
132    }
133    files.sort_by_key(|f| f.version);
134    Ok(MigrationDir {
135        role: role.to_string(),
136        path: path.to_path_buf(),
137        files,
138    })
139}
140
141pub mod apply;
142pub mod config;
143pub mod error;
144pub mod git;
145pub mod model;
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn parses_zero_padded() {
153        assert_eq!(
154            parse_filename("0286_captable.sql"),
155            Some((286, 4, "captable"))
156        );
157    }
158
159    #[test]
160    fn parses_multi_underscore_description() {
161        assert_eq!(
162            parse_filename("0012_add_forms_index.sql"),
163            Some((12, 4, "add_forms_index"))
164        );
165    }
166
167    #[test]
168    fn rejects_no_leading_digits() {
169        assert_eq!(parse_filename("captable_0286.sql"), None);
170    }
171
172    #[test]
173    fn rejects_missing_underscore() {
174        assert_eq!(parse_filename("0286captable.sql"), None);
175    }
176
177    #[test]
178    fn rejects_non_sql() {
179        assert_eq!(parse_filename("0286_x.txt"), None);
180    }
181
182    #[test]
183    fn scan_missing_dir_is_empty() {
184        let d = scan_dir("platform", Path::new("/no/such/dir")).unwrap();
185        assert!(d.files.is_empty());
186    }
187
188    #[test]
189    fn scan_reads_and_sorts() {
190        let tmp = tempfile::tempdir().unwrap();
191        for n in ["0002_b.sql", "0001_a.sql", "notes.md"] {
192            std::fs::write(tmp.path().join(n), "").unwrap();
193        }
194        let d = scan_dir("platform", tmp.path()).unwrap();
195        assert_eq!(d.files.len(), 2);
196        assert_eq!(d.files[0].version, 1);
197        assert_eq!(d.files[1].version, 2);
198    }
199}