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).
54///
55/// # Examples
56///
57/// ```
58/// use migrate_guard::MigrationFile;
59/// use migrate_guard::model::plan_renumber;
60/// # use std::path::PathBuf;
61/// # fn file(version: u64, desc: &str) -> MigrationFile {
62/// #     MigrationFile {
63/// #         version, width: 4, description: desc.into(),
64/// #         filename: format!("{version:04}_{desc}.sql"),
65/// #         path: PathBuf::from(format!("migrations/{version:04}_{desc}.sql")),
66/// #     }
67/// # }
68/// // Another branch already took 0002/0003, so floor = 3: our 0002_mine
69/// // is renumbered to 0004_mine.
70/// let mine = [file(2, "mine")];
71/// let plan = plan_renumber(&mine, 3);
72/// assert_eq!(plan.len(), 1);
73/// assert_eq!(plan[0].new_version, 4);
74/// assert_eq!(plan[0].to.file_name().unwrap(), "0004_mine.sql");
75///
76/// // Already above the floor → nothing to do (idempotent).
77/// assert!(plan_renumber(&[file(9, "later")], 3).is_empty());
78/// ```
79pub fn plan_renumber(topic_files: &[MigrationFile], floor: u64) -> Vec<Rename> {
80    let mut sorted: Vec<&MigrationFile> = topic_files.iter().collect();
81    sorted.sort_by_key(|f| f.version);
82
83    let needs = sorted.iter().any(|f| f.version <= floor);
84    if !needs {
85        return Vec::new();
86    }
87
88    let width = topic_files.iter().map(|f| f.width).max().unwrap_or(4);
89    let mut plan = Vec::new();
90    for (k, f) in sorted.iter().enumerate() {
91        let new_version = floor + 1 + k as u64;
92        if new_version != f.version {
93            let new_name = format!("{new_version:0width$}_{}.sql", f.description);
94            let to = f.path.with_file_name(new_name);
95            plan.push(Rename {
96                from: f.path.clone(),
97                to,
98                old_version: f.version,
99                new_version,
100            });
101        }
102    }
103    plan
104}
105
106/// Group observations by (role, version); return only versions seen >= twice.
107/// Same version in two DIFFERENT roles is not a collision.
108pub fn detect_collisions(observations: &[Observation]) -> Vec<Collision> {
109    let mut map: BTreeMap<(String, u64), Vec<&Observation>> = BTreeMap::new();
110    for o in observations {
111        map.entry((o.role.clone(), o.version)).or_default().push(o);
112    }
113    let mut out = Vec::new();
114    for ((role, version), group) in map {
115        // A real collision = the same version claimed by >= 2 DISTINCT
116        // filenames. The same filename seen on multiple branches is one
117        // migration propagated, not a collision.
118        let distinct: std::collections::BTreeSet<&str> =
119            group.iter().map(|o| o.file.filename.as_str()).collect();
120        if distinct.len() >= 2 {
121            out.push(Collision {
122                role,
123                version,
124                occurrences: group
125                    .iter()
126                    .map(|o| Located {
127                        source: o.source.clone(),
128                        filename: o.file.filename.clone(),
129                        path: o.file.path.clone(),
130                    })
131                    .collect(),
132            });
133        }
134    }
135    out
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::path::Path;
142
143    fn mf(version: u64, width: usize, desc: &str) -> MigrationFile {
144        MigrationFile {
145            version,
146            width,
147            description: desc.to_string(),
148            filename: format!("{version:0width$}_{desc}.sql"),
149            path: Path::new("migrations").join(format!("{version:0width$}_{desc}.sql")),
150        }
151    }
152
153    #[test]
154    fn max_of_empty_is_zero() {
155        assert_eq!(max_version(&[]), 0);
156    }
157
158    #[test]
159    fn no_collision_is_noop() {
160        let files = [mf(287, 4, "a"), mf(288, 4, "b")];
161        assert!(plan_renumber(&files, 286).is_empty());
162    }
163
164    #[test]
165    fn collision_packs_above_floor_in_order() {
166        let files = [mf(286, 4, "a"), mf(287, 4, "b")];
167        let plan = plan_renumber(&files, 288);
168        assert_eq!(plan.len(), 2);
169        assert_eq!(plan[0].new_version, 289);
170        assert_eq!(plan[1].new_version, 290);
171        assert_eq!(plan[0].to.file_name().unwrap(), "0289_a.sql");
172        assert_eq!(plan[1].to.file_name().unwrap(), "0290_b.sql");
173    }
174
175    #[test]
176    fn renumber_is_idempotent() {
177        let files = [mf(286, 4, "a")];
178        let plan = plan_renumber(&files, 288);
179        assert_eq!(plan[0].new_version, 289);
180        let after = [mf(289, 4, "a")];
181        assert!(plan_renumber(&after, 288).is_empty());
182    }
183
184    #[test]
185    fn widens_at_decade_boundary() {
186        let files = [mf(1, 4, "a")];
187        let plan = plan_renumber(&files, 9999);
188        assert_eq!(plan[0].to.file_name().unwrap(), "10000_a.sql");
189    }
190
191    fn obs(role: &str, version: u64, src: Source) -> Observation {
192        Observation {
193            role: role.to_string(),
194            version,
195            source: src,
196            file: mf(version, 4, "x"),
197        }
198    }
199
200    fn obs_named(role: &str, version: u64, desc: &str, src: Source) -> Observation {
201        Observation {
202            role: role.to_string(),
203            version,
204            source: src,
205            file: mf(version, 4, desc),
206        }
207    }
208
209    #[test]
210    fn same_version_across_roles_is_not_a_collision() {
211        let o = [
212            obs("platform", 145, Source::WorkingTree),
213            obs("tenant", 145, Source::Branch("b".into())),
214        ];
215        assert!(detect_collisions(&o).is_empty());
216    }
217
218    #[test]
219    fn same_version_distinct_files_collides() {
220        // version 286 claimed by two DIFFERENT files → collision.
221        let o = [
222            obs_named("platform", 286, "alpha", Source::Branch("a".into())),
223            obs_named("platform", 286, "beta", Source::WorkingTree),
224        ];
225        let c = detect_collisions(&o);
226        assert_eq!(c.len(), 1);
227        assert_eq!(c[0].version, 286);
228        assert_eq!(c[0].occurrences.len(), 2);
229    }
230
231    #[test]
232    fn same_version_same_file_is_not_a_collision() {
233        // The same migration file on two branches is NOT a collision.
234        let o = [
235            obs_named("platform", 286, "x", Source::Branch("a".into())),
236            obs_named("platform", 286, "x", Source::WorkingTree),
237        ];
238        assert!(detect_collisions(&o).is_empty());
239    }
240}