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> {
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
81pub 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 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 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 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}