1use crate::MigrationFile;
4use std::collections::BTreeMap;
5use std::path::PathBuf;
6
7#[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#[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
45pub fn max_version(files: &[MigrationFile]) -> u64 {
47 files.iter().map(|f| f.version).max().unwrap_or(0)
48}
49
50pub 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
106pub 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 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 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 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}