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}