migrate-guard 0.1.0

Detect and auto-renumber colliding sqlx migration version numbers across git branches.
Documentation
//! Pure collision + renumber logic. No I/O.

use crate::MigrationFile;
use std::collections::BTreeMap;
use std::path::PathBuf;

/// Where a version was observed.
#[derive(Debug, Clone)]
pub enum Source {
    WorkingTree,
    Branch(String),
}

#[derive(Debug, Clone)]
pub struct Located {
    pub source: Source,
    pub filename: String,
    pub path: PathBuf,
}

/// One observation for `detect_collisions`.
#[derive(Debug, Clone)]
pub struct Observation {
    pub role: String,
    pub version: u64,
    pub source: Source,
    pub file: MigrationFile,
}

#[derive(Debug)]
pub struct Collision {
    pub role: String,
    pub version: u64,
    pub occurrences: Vec<Located>,
}

#[derive(Debug, PartialEq, Eq)]
pub struct Rename {
    pub from: PathBuf,
    pub to: PathBuf,
    pub old_version: u64,
    pub new_version: u64,
}

/// Highest version among files (0 if empty).
pub fn max_version(files: &[MigrationFile]) -> u64 {
    files.iter().map(|f| f.version).max().unwrap_or(0)
}

/// Assign a contiguous block of versions strictly above `floor` to the topic
/// files, preserving their relative order — but ONLY if at least one topic
/// file is at or below the floor (i.e. actually collides). If every topic file
/// is already above the floor, return an empty plan (idempotent no-op).
pub fn plan_renumber(topic_files: &[MigrationFile], floor: u64) -> Vec<Rename> {
    let mut sorted: Vec<&MigrationFile> = topic_files.iter().collect();
    sorted.sort_by_key(|f| f.version);

    let needs = sorted.iter().any(|f| f.version <= floor);
    if !needs {
        return Vec::new();
    }

    let width = topic_files.iter().map(|f| f.width).max().unwrap_or(4);
    let mut plan = Vec::new();
    for (k, f) in sorted.iter().enumerate() {
        let new_version = floor + 1 + k as u64;
        if new_version != f.version {
            let new_name = format!("{new_version:0width$}_{}.sql", f.description);
            let to = f.path.with_file_name(new_name);
            plan.push(Rename {
                from: f.path.clone(),
                to,
                old_version: f.version,
                new_version,
            });
        }
    }
    plan
}

/// Group observations by (role, version); return only versions seen >= twice.
/// Same version in two DIFFERENT roles is not a collision.
pub fn detect_collisions(observations: &[Observation]) -> Vec<Collision> {
    let mut map: BTreeMap<(String, u64), Vec<&Observation>> = BTreeMap::new();
    for o in observations {
        map.entry((o.role.clone(), o.version)).or_default().push(o);
    }
    let mut out = Vec::new();
    for ((role, version), group) in map {
        // A real collision = the same version claimed by >= 2 DISTINCT
        // filenames. The same filename seen on multiple branches is one
        // migration propagated, not a collision.
        let distinct: std::collections::BTreeSet<&str> =
            group.iter().map(|o| o.file.filename.as_str()).collect();
        if distinct.len() >= 2 {
            out.push(Collision {
                role,
                version,
                occurrences: group
                    .iter()
                    .map(|o| Located {
                        source: o.source.clone(),
                        filename: o.file.filename.clone(),
                        path: o.file.path.clone(),
                    })
                    .collect(),
            });
        }
    }
    out
}

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

    fn mf(version: u64, width: usize, desc: &str) -> MigrationFile {
        MigrationFile {
            version,
            width,
            description: desc.to_string(),
            filename: format!("{version:0width$}_{desc}.sql"),
            path: Path::new("migrations").join(format!("{version:0width$}_{desc}.sql")),
        }
    }

    #[test]
    fn max_of_empty_is_zero() {
        assert_eq!(max_version(&[]), 0);
    }

    #[test]
    fn no_collision_is_noop() {
        let files = [mf(287, 4, "a"), mf(288, 4, "b")];
        assert!(plan_renumber(&files, 286).is_empty());
    }

    #[test]
    fn collision_packs_above_floor_in_order() {
        let files = [mf(286, 4, "a"), mf(287, 4, "b")];
        let plan = plan_renumber(&files, 288);
        assert_eq!(plan.len(), 2);
        assert_eq!(plan[0].new_version, 289);
        assert_eq!(plan[1].new_version, 290);
        assert_eq!(plan[0].to.file_name().unwrap(), "0289_a.sql");
        assert_eq!(plan[1].to.file_name().unwrap(), "0290_b.sql");
    }

    #[test]
    fn renumber_is_idempotent() {
        let files = [mf(286, 4, "a")];
        let plan = plan_renumber(&files, 288);
        assert_eq!(plan[0].new_version, 289);
        let after = [mf(289, 4, "a")];
        assert!(plan_renumber(&after, 288).is_empty());
    }

    #[test]
    fn widens_at_decade_boundary() {
        let files = [mf(1, 4, "a")];
        let plan = plan_renumber(&files, 9999);
        assert_eq!(plan[0].to.file_name().unwrap(), "10000_a.sql");
    }

    fn obs(role: &str, version: u64, src: Source) -> Observation {
        Observation {
            role: role.to_string(),
            version,
            source: src,
            file: mf(version, 4, "x"),
        }
    }

    fn obs_named(role: &str, version: u64, desc: &str, src: Source) -> Observation {
        Observation {
            role: role.to_string(),
            version,
            source: src,
            file: mf(version, 4, desc),
        }
    }

    #[test]
    fn same_version_across_roles_is_not_a_collision() {
        let o = [
            obs("platform", 145, Source::WorkingTree),
            obs("tenant", 145, Source::Branch("b".into())),
        ];
        assert!(detect_collisions(&o).is_empty());
    }

    #[test]
    fn same_version_distinct_files_collides() {
        // version 286 claimed by two DIFFERENT files → collision.
        let o = [
            obs_named("platform", 286, "alpha", Source::Branch("a".into())),
            obs_named("platform", 286, "beta", Source::WorkingTree),
        ];
        let c = detect_collisions(&o);
        assert_eq!(c.len(), 1);
        assert_eq!(c[0].version, 286);
        assert_eq!(c[0].occurrences.len(), 2);
    }

    #[test]
    fn same_version_same_file_is_not_a_collision() {
        // The same migration file on two branches is NOT a collision.
        let o = [
            obs_named("platform", 286, "x", Source::Branch("a".into())),
            obs_named("platform", 286, "x", Source::WorkingTree),
        ];
        assert!(detect_collisions(&o).is_empty());
    }
}