Skip to main content

migrate_guard/
model.rs

1//! Pure collision + renumber logic. No I/O.
2
3use crate::MigrationFile;
4use std::collections::BTreeMap;
5use std::path::PathBuf;
6
7/// Where a version was observed.
8#[derive(Debug, Clone)]
9pub enum Source {
10    WorkingTree,
11    Branch(String),
12}
13
14#[derive(Debug, Clone)]
15pub struct Located {
16    pub source: Source,
17    pub filename: String,
18    pub path: PathBuf,
19}
20
21/// One observation for `detect_collisions`.
22#[derive(Debug, Clone)]
23pub struct Observation {
24    pub role: String,
25    pub version: u64,
26    pub source: Source,
27    pub file: MigrationFile,
28}
29
30#[derive(Debug)]
31pub struct Collision {
32    pub role: String,
33    pub version: u64,
34    pub occurrences: Vec<Located>,
35}
36
37#[derive(Debug, PartialEq, Eq)]
38pub struct Rename {
39    pub from: PathBuf,
40    pub to: PathBuf,
41    pub old_version: u64,
42    pub new_version: u64,
43}
44
45/// Highest version among files (0 if empty).
46pub fn max_version(files: &[MigrationFile]) -> u64 {
47    files.iter().map(|f| f.version).max().unwrap_or(0)
48}
49
50/// Assign a contiguous block of versions strictly above `floor` to the topic
51/// files, preserving their relative order — but ONLY if at least one topic
52/// file is at or below the floor (i.e. actually collides). If every topic file
53/// is already above the floor, return an empty plan (idempotent no-op).
54pub fn plan_renumber(topic_files: &[MigrationFile], floor: u64) -> Vec<Rename> {
55    let mut sorted: Vec<&MigrationFile> = topic_files.iter().collect();
56    sorted.sort_by_key(|f| f.version);
57
58    let needs = sorted.iter().any(|f| f.version <= floor);
59    if !needs {
60        return Vec::new();
61    }
62
63    let width = topic_files.iter().map(|f| f.width).max().unwrap_or(4);
64    let mut plan = Vec::new();
65    for (k, f) in sorted.iter().enumerate() {
66        let new_version = floor + 1 + k as u64;
67        if new_version != f.version {
68            let new_name = format!("{new_version:0width$}_{}.sql", f.description);
69            let to = f.path.with_file_name(new_name);
70            plan.push(Rename {
71                from: f.path.clone(),
72                to,
73                old_version: f.version,
74                new_version,
75            });
76        }
77    }
78    plan
79}
80
81/// Group observations by (role, version); return only versions seen >= twice.
82/// Same version in two DIFFERENT roles is not a collision.
83pub fn detect_collisions(observations: &[Observation]) -> Vec<Collision> {
84    let mut map: BTreeMap<(String, u64), Vec<&Observation>> = BTreeMap::new();
85    for o in observations {
86        map.entry((o.role.clone(), o.version)).or_default().push(o);
87    }
88    let mut out = Vec::new();
89    for ((role, version), group) in map {
90        // A real collision = the same version claimed by >= 2 DISTINCT
91        // filenames. The same filename seen on multiple branches is one
92        // migration propagated, not a collision.
93        let distinct: std::collections::BTreeSet<&str> =
94            group.iter().map(|o| o.file.filename.as_str()).collect();
95        if distinct.len() >= 2 {
96            out.push(Collision {
97                role,
98                version,
99                occurrences: group
100                    .iter()
101                    .map(|o| Located {
102                        source: o.source.clone(),
103                        filename: o.file.filename.clone(),
104                        path: o.file.path.clone(),
105                    })
106                    .collect(),
107            });
108        }
109    }
110    out
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::path::Path;
117
118    fn mf(version: u64, width: usize, desc: &str) -> MigrationFile {
119        MigrationFile {
120            version,
121            width,
122            description: desc.to_string(),
123            filename: format!("{version:0width$}_{desc}.sql"),
124            path: Path::new("migrations").join(format!("{version:0width$}_{desc}.sql")),
125        }
126    }
127
128    #[test]
129    fn max_of_empty_is_zero() {
130        assert_eq!(max_version(&[]), 0);
131    }
132
133    #[test]
134    fn no_collision_is_noop() {
135        let files = [mf(287, 4, "a"), mf(288, 4, "b")];
136        assert!(plan_renumber(&files, 286).is_empty());
137    }
138
139    #[test]
140    fn collision_packs_above_floor_in_order() {
141        let files = [mf(286, 4, "a"), mf(287, 4, "b")];
142        let plan = plan_renumber(&files, 288);
143        assert_eq!(plan.len(), 2);
144        assert_eq!(plan[0].new_version, 289);
145        assert_eq!(plan[1].new_version, 290);
146        assert_eq!(plan[0].to.file_name().unwrap(), "0289_a.sql");
147        assert_eq!(plan[1].to.file_name().unwrap(), "0290_b.sql");
148    }
149
150    #[test]
151    fn renumber_is_idempotent() {
152        let files = [mf(286, 4, "a")];
153        let plan = plan_renumber(&files, 288);
154        assert_eq!(plan[0].new_version, 289);
155        let after = [mf(289, 4, "a")];
156        assert!(plan_renumber(&after, 288).is_empty());
157    }
158
159    #[test]
160    fn widens_at_decade_boundary() {
161        let files = [mf(1, 4, "a")];
162        let plan = plan_renumber(&files, 9999);
163        assert_eq!(plan[0].to.file_name().unwrap(), "10000_a.sql");
164    }
165
166    fn obs(role: &str, version: u64, src: Source) -> Observation {
167        Observation {
168            role: role.to_string(),
169            version,
170            source: src,
171            file: mf(version, 4, "x"),
172        }
173    }
174
175    fn obs_named(role: &str, version: u64, desc: &str, src: Source) -> Observation {
176        Observation {
177            role: role.to_string(),
178            version,
179            source: src,
180            file: mf(version, 4, desc),
181        }
182    }
183
184    #[test]
185    fn same_version_across_roles_is_not_a_collision() {
186        let o = [
187            obs("platform", 145, Source::WorkingTree),
188            obs("tenant", 145, Source::Branch("b".into())),
189        ];
190        assert!(detect_collisions(&o).is_empty());
191    }
192
193    #[test]
194    fn same_version_distinct_files_collides() {
195        // version 286 claimed by two DIFFERENT files → collision.
196        let o = [
197            obs_named("platform", 286, "alpha", Source::Branch("a".into())),
198            obs_named("platform", 286, "beta", Source::WorkingTree),
199        ];
200        let c = detect_collisions(&o);
201        assert_eq!(c.len(), 1);
202        assert_eq!(c[0].version, 286);
203        assert_eq!(c[0].occurrences.len(), 2);
204    }
205
206    #[test]
207    fn same_version_same_file_is_not_a_collision() {
208        // The same migration file on two branches is NOT a collision.
209        let o = [
210            obs_named("platform", 286, "x", Source::Branch("a".into())),
211            obs_named("platform", 286, "x", Source::WorkingTree),
212        ];
213        assert!(detect_collisions(&o).is_empty());
214    }
215}