use crate::MigrationFile;
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub enum Source {
WorkingTree,
Branch(String),
}
#[derive(Debug, Clone)]
pub struct Located {
pub source: Source,
pub filename: String,
pub path: PathBuf,
}
#[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,
}
pub fn max_version(files: &[MigrationFile]) -> u64 {
files.iter().map(|f| f.version).max().unwrap_or(0)
}
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
}
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 {
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() {
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() {
let o = [
obs_named("platform", 286, "x", Source::Branch("a".into())),
obs_named("platform", 286, "x", Source::WorkingTree),
];
assert!(detect_collisions(&o).is_empty());
}
}