migrate-guard 0.1.1

Detect and auto-renumber colliding sqlx migration version numbers across git branches.
Documentation
//! Detect and auto-renumber colliding sqlx migration version numbers across
//! git branches.
//!
//! sqlx assigns a migration's version at author time, so two branches that both
//! add `NNNN_*.sql` at the same `NNNN` merge cleanly, then fail at test time
//! with `UNIQUE constraint failed: _sqlx_migrations.version`. `migrate-guard`
//! catches this before merge and renumbers your topic branch above the global
//! max. A collision is one version claimed by two *different* files — the same
//! file seen on multiple branches is not a collision.
//!
//! # As a CLI
//!
//! The `cargo migrate-guard` subcommand runs at your repo root, configured by a
//! `migrate-guard.toml` that lists your migration directories:
//!
//! ```text
//! cargo install migrate-guard
//!
//! cargo migrate-guard check                 # CI gate: non-zero exit on collision
//! cargo migrate-guard renumber              # dry run: show the plan
//! cargo migrate-guard renumber --apply      # renumber this branch + rewrite include_str! refs
//! cargo migrate-guard max                   # print the max version per role
//! ```
//!
//! ```toml
//! # migrate-guard.toml
//! base_ref = "main"
//! reference_globs = ["src/**/*.rs"]
//!
//! [[dir]]
//! role = "platform"
//! path = "migrations"
//! ```
//!
//! # As a library
//!
//! The pure collision + renumber logic is exposed and needs no git or
//! filesystem. Parse a filename:
//!
//! ```
//! use migrate_guard::parse_filename;
//!
//! assert_eq!(parse_filename("0286_add_index.sql"), Some((286, 4, "add_index")));
//! assert_eq!(parse_filename("not-a-migration.sql"), None);
//! ```
//!
//! Detect a collision (same version, two different files):
//!
//! ```
//! use migrate_guard::MigrationFile;
//! use migrate_guard::model::{detect_collisions, Observation, Source};
//! # use std::path::PathBuf;
//! # fn file(version: u64, filename: &str) -> MigrationFile {
//! #     MigrationFile {
//! #         version, width: 4, description: String::new(),
//! #         filename: filename.into(), path: PathBuf::from(filename),
//! #     }
//! # }
//! let observations = vec![
//!     Observation { role: "platform".into(), version: 2,
//!         source: Source::Branch("feat-a".into()), file: file(2, "0002_a.sql") },
//!     Observation { role: "platform".into(), version: 2,
//!         source: Source::WorkingTree, file: file(2, "0002_b.sql") },
//! ];
//! let collisions = detect_collisions(&observations);
//! assert_eq!(collisions.len(), 1);
//! assert_eq!(collisions[0].version, 2);
//! ```

use std::path::{Path, PathBuf};

/// One parsed migration file within a numbering namespace.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationFile {
    pub version: u64,
    pub width: usize,
    pub description: String,
    pub filename: String,
    pub path: PathBuf,
}

/// An independent numbering namespace (platform vs tenant, etc.).
#[derive(Debug, Clone)]
pub struct MigrationDir {
    pub role: String,
    pub path: PathBuf,
    pub files: Vec<MigrationFile>,
}

/// Parse a migration filename: a leading run of ASCII digits, then '_',
/// then a description, then ".sql". Returns (version, digit_width, desc).
///
/// # Examples
///
/// ```
/// use migrate_guard::parse_filename;
///
/// assert_eq!(parse_filename("0286_captable.sql"), Some((286, 4, "captable")));
/// assert_eq!(parse_filename("12_short.sql"), Some((12, 2, "short")));
/// assert_eq!(parse_filename("no_leading_digits.sql"), None);
/// assert_eq!(parse_filename("0001_x.txt"), None);
/// ```
pub fn parse_filename(name: &str) -> Option<(u64, usize, &str)> {
    let stem = name.strip_suffix(".sql")?;
    let digits: String = stem.chars().take_while(|c| c.is_ascii_digit()).collect();
    if digits.is_empty() {
        return None;
    }
    let rest = &stem[digits.len()..];
    let desc = rest.strip_prefix('_')?;
    let version = digits.parse::<u64>().ok()?;
    Some((version, digits.len(), desc))
}

/// Scan a directory for migration files (missing dir → empty), sorted by version.
pub fn scan_dir(role: &str, path: &Path) -> std::io::Result<MigrationDir> {
    let mut files = Vec::new();
    if path.exists() {
        for entry in std::fs::read_dir(path)? {
            let entry = entry?;
            let name = entry.file_name().to_string_lossy().into_owned();
            if let Some((version, width, desc)) = parse_filename(&name) {
                files.push(MigrationFile {
                    version,
                    width,
                    description: desc.to_string(),
                    filename: name,
                    path: entry.path(),
                });
            }
        }
    }
    files.sort_by_key(|f| f.version);
    Ok(MigrationDir {
        role: role.to_string(),
        path: path.to_path_buf(),
        files,
    })
}

pub mod apply;
pub mod config;
pub mod error;
pub mod git;
pub mod model;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_zero_padded() {
        assert_eq!(
            parse_filename("0286_captable.sql"),
            Some((286, 4, "captable"))
        );
    }

    #[test]
    fn parses_multi_underscore_description() {
        assert_eq!(
            parse_filename("0012_add_forms_index.sql"),
            Some((12, 4, "add_forms_index"))
        );
    }

    #[test]
    fn rejects_no_leading_digits() {
        assert_eq!(parse_filename("captable_0286.sql"), None);
    }

    #[test]
    fn rejects_missing_underscore() {
        assert_eq!(parse_filename("0286captable.sql"), None);
    }

    #[test]
    fn rejects_non_sql() {
        assert_eq!(parse_filename("0286_x.txt"), None);
    }

    #[test]
    fn scan_missing_dir_is_empty() {
        let d = scan_dir("platform", Path::new("/no/such/dir")).unwrap();
        assert!(d.files.is_empty());
    }

    #[test]
    fn scan_reads_and_sorts() {
        let tmp = tempfile::tempdir().unwrap();
        for n in ["0002_b.sql", "0001_a.sql", "notes.md"] {
            std::fs::write(tmp.path().join(n), "").unwrap();
        }
        let d = scan_dir("platform", tmp.path()).unwrap();
        assert_eq!(d.files.len(), 2);
        assert_eq!(d.files[0].version, 1);
        assert_eq!(d.files[1].version, 2);
    }
}