migrate-guard 0.1.0

Detect and auto-renumber colliding sqlx migration version numbers across git branches.
Documentation
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).
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);
    }
}