1use crate::constants::BASELINE_FILENAME_PREFIX;
2use anyhow::Result;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
7pub struct ParsedMigration {
8 pub path: PathBuf,
9 pub version: u64,
10 pub description: String,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ParsedBaseline {
16 pub path: PathBuf,
17 pub version: u64,
18}
19
20pub fn parse_migration_filename(filename: &str) -> Option<(u64, String)> {
23 if !filename.ends_with(".sql") {
24 return None;
25 }
26
27 let name_without_ext = &filename[..filename.len() - 4]; let name_without_prefix = name_without_ext
31 .strip_prefix('V')
32 .unwrap_or(name_without_ext);
33
34 let parts: Vec<&str> = name_without_prefix.splitn(2, '_').collect();
35
36 if parts.len() != 2 {
37 return None;
38 }
39
40 let version = parts[0].parse::<u64>().ok()?;
41 let description = parts[1].to_string();
42
43 Some((version, description))
44}
45
46pub fn parse_baseline_filename(filename: &str) -> Option<u64> {
49 if !filename.starts_with(BASELINE_FILENAME_PREFIX) || !filename.ends_with(".sql") {
50 return None;
51 }
52
53 let version_str = filename
54 .strip_prefix(BASELINE_FILENAME_PREFIX)?
55 .strip_suffix(".sql")?;
56
57 let version_str = version_str.strip_prefix('V').unwrap_or(version_str);
59
60 version_str.parse::<u64>().ok()
61}
62
63pub fn generate_baseline_filename(version: u64) -> String {
65 format!("{}{}.sql", BASELINE_FILENAME_PREFIX, version)
66}
67
68pub fn discover_migrations(migrations_dir: &Path) -> Result<Vec<ParsedMigration>> {
70 let mut migrations = Vec::new();
71
72 if !migrations_dir.exists() {
73 return Ok(migrations);
74 }
75
76 for entry in std::fs::read_dir(migrations_dir)? {
77 let entry = entry?;
78 let path = entry.path();
79
80 if path.extension().is_some_and(|ext| ext == "sql")
81 && let Some(filename) = path.file_name().and_then(|n| n.to_str())
82 && let Some((version, description)) = parse_migration_filename(filename)
83 {
84 migrations.push(ParsedMigration {
85 path,
86 version,
87 description,
88 });
89 }
90 }
91
92 migrations.sort_by_key(|m| m.version);
94
95 Ok(migrations)
96}
97
98pub fn discover_baselines(baselines_dir: &Path) -> Result<Vec<ParsedBaseline>> {
100 let mut baselines = Vec::new();
101
102 if !baselines_dir.exists() {
103 return Ok(baselines);
104 }
105
106 for entry in std::fs::read_dir(baselines_dir)? {
107 let entry = entry?;
108 let path = entry.path();
109
110 if path.extension().is_some_and(|ext| ext == "sql")
111 && let Some(filename) = path.file_name().and_then(|n| n.to_str())
112 && let Some(version) = parse_baseline_filename(filename)
113 {
114 baselines.push(ParsedBaseline { path, version });
115 }
116 }
117
118 baselines.sort_by_key(|b| b.version);
120
121 Ok(baselines)
122}
123
124pub fn find_latest_migration(migrations_dir: &Path) -> Result<Option<ParsedMigration>> {
126 let migrations = discover_migrations(migrations_dir)?;
127 Ok(migrations.last().cloned())
128}
129
130pub fn find_latest_baseline(baselines_dir: &Path) -> Result<Option<ParsedBaseline>> {
132 let baselines = discover_baselines(baselines_dir)?;
133 Ok(baselines.last().cloned())
134}
135
136pub fn find_baseline_for_version(
139 baselines_dir: &Path,
140 target_version: u64,
141) -> Result<Option<ParsedBaseline>> {
142 let baselines = discover_baselines(baselines_dir)?;
143
144 let previous_baseline = baselines
146 .iter()
147 .rev()
148 .find(|b| b.version < target_version)
149 .cloned();
150
151 Ok(previous_baseline)
152}
153
154pub fn find_migration_by_version(
156 migrations_dir: &Path,
157 version_str: &str,
158) -> Result<Option<ParsedMigration>> {
159 let migrations = discover_migrations(migrations_dir)?;
160
161 let version_str = version_str.strip_prefix("V").unwrap_or(version_str);
163
164 if let Ok(exact_version) = version_str.parse::<u64>()
166 && let Some(migration) = migrations.iter().find(|m| m.version == exact_version)
167 {
168 return Ok(Some(migration.clone()));
169 }
170
171 let matching_migrations: Vec<_> = migrations
173 .iter()
174 .filter(|m| m.version.to_string().starts_with(version_str))
175 .collect();
176
177 match matching_migrations.len() {
178 0 => Ok(None),
179 1 => Ok(Some(matching_migrations[0].clone())),
180 _ => {
181 let versions: Vec<String> = matching_migrations
183 .iter()
184 .map(|m| m.version.to_string())
185 .collect();
186 Err(anyhow::anyhow!(
187 "Ambiguous migration version '{}'. Matches: {}. Please be more specific.",
188 version_str,
189 versions.join(", ")
190 ))
191 }
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use std::env;
199
200 #[test]
201 fn test_parse_migration_filename() {
202 assert_eq!(
204 parse_migration_filename("V1734567890_add_user_index.sql"),
205 Some((1734567890, "add_user_index".to_string()))
206 );
207
208 assert_eq!(
209 parse_migration_filename("V1234567890_create_tables.sql"),
210 Some((1234567890, "create_tables".to_string()))
211 );
212
213 assert_eq!(
215 parse_migration_filename("1734567890_add_user_index.sql"),
216 Some((1734567890, "add_user_index".to_string()))
217 );
218
219 assert_eq!(
220 parse_migration_filename("1234567890_create_tables.sql"),
221 Some((1234567890, "create_tables".to_string()))
222 );
223
224 assert_eq!(parse_migration_filename("V1734567890_add_user_index"), None); assert_eq!(parse_migration_filename("V1734567890.sql"), None); assert_eq!(parse_migration_filename("1734567890.sql"), None); assert_eq!(parse_migration_filename("Vabc_description.sql"), None); assert_eq!(parse_migration_filename("abc_description.sql"), None); assert_eq!(parse_migration_filename("baseline_V1234567890.sql"), None); }
232
233 #[test]
234 fn test_parse_baseline_filename() {
235 assert_eq!(
237 parse_baseline_filename("baseline_1734567890.sql"),
238 Some(1734567890)
239 );
240 assert_eq!(
241 parse_baseline_filename("baseline_1234567890.sql"),
242 Some(1234567890)
243 );
244
245 assert_eq!(
247 parse_baseline_filename("baseline_V1734567890.sql"),
248 Some(1734567890)
249 );
250 assert_eq!(
251 parse_baseline_filename("baseline_V1234567890.sql"),
252 Some(1234567890)
253 );
254
255 assert_eq!(parse_baseline_filename("V1734567890_description.sql"), None); assert_eq!(parse_baseline_filename("baseline_V1734567890"), None); assert_eq!(parse_baseline_filename("baseline_Vabc.sql"), None); }
260
261 #[test]
262 fn test_generate_filenames() {
263 assert_eq!(
264 generate_baseline_filename(1734567890),
265 "baseline_1734567890.sql"
266 );
267 }
268
269 #[test]
270 fn test_discover_migrations() {
271 let temp_dir = env::temp_dir().join("pgmt_test_discover_migrations");
272 let _ = std::fs::remove_dir_all(&temp_dir);
273 std::fs::create_dir_all(&temp_dir).unwrap();
274
275 std::fs::write(
277 temp_dir.join("V1000000000_first_migration.sql"),
278 "-- First migration (V prefix)",
279 )
280 .unwrap();
281 std::fs::write(
282 temp_dir.join("2000000000_second_migration.sql"),
283 "-- Second migration (no prefix)",
284 )
285 .unwrap();
286 std::fs::write(
287 temp_dir.join("V3000000000_third_migration.sql"),
288 "-- Third migration (V prefix)",
289 )
290 .unwrap();
291 std::fs::write(temp_dir.join("invalid_file.sql"), "-- Invalid").unwrap();
292 std::fs::write(temp_dir.join("readme.txt"), "-- Not SQL").unwrap();
293
294 let migrations = discover_migrations(&temp_dir).unwrap();
295
296 assert_eq!(migrations.len(), 3);
297 assert_eq!(migrations[0].version, 1000000000);
298 assert_eq!(migrations[0].description, "first_migration");
299 assert_eq!(migrations[1].version, 2000000000);
300 assert_eq!(migrations[1].description, "second_migration");
301 assert_eq!(migrations[2].version, 3000000000);
302 assert_eq!(migrations[2].description, "third_migration");
303
304 let _ = std::fs::remove_dir_all(&temp_dir);
306 }
307
308 #[test]
309 fn test_discover_baselines() {
310 let temp_dir = env::temp_dir().join("pgmt_test_discover_baselines");
311 let _ = std::fs::remove_dir_all(&temp_dir);
312 std::fs::create_dir_all(&temp_dir).unwrap();
313
314 std::fs::write(
316 temp_dir.join("baseline_V1000000000.sql"),
317 "-- First baseline (old format)",
318 )
319 .unwrap();
320 std::fs::write(
321 temp_dir.join("baseline_2000000000.sql"),
322 "-- Second baseline (new format)",
323 )
324 .unwrap();
325 std::fs::write(
326 temp_dir.join("V1000000000_migration.sql"),
327 "-- Migration file",
328 )
329 .unwrap();
330 std::fs::write(temp_dir.join("readme.txt"), "-- Not SQL").unwrap();
331
332 let baselines = discover_baselines(&temp_dir).unwrap();
333
334 assert_eq!(baselines.len(), 2);
335 assert_eq!(baselines[0].version, 1000000000);
336 assert_eq!(baselines[1].version, 2000000000);
337
338 let _ = std::fs::remove_dir_all(&temp_dir);
340 }
341
342 #[test]
343 fn test_find_latest_migration() {
344 let temp_dir = env::temp_dir().join("pgmt_test_find_latest_migration");
345 let _ = std::fs::remove_dir_all(&temp_dir);
346 std::fs::create_dir_all(&temp_dir).unwrap();
347
348 assert!(find_latest_migration(&temp_dir).unwrap().is_none());
350
351 std::fs::write(temp_dir.join("V1000000000_first_migration.sql"), "-- First").unwrap();
353 std::fs::write(temp_dir.join("V3000000000_third_migration.sql"), "-- Third").unwrap();
354 std::fs::write(
355 temp_dir.join("V2000000000_second_migration.sql"),
356 "-- Second",
357 )
358 .unwrap();
359
360 let latest = find_latest_migration(&temp_dir).unwrap().unwrap();
361 assert_eq!(latest.version, 3000000000);
362 assert_eq!(latest.description, "third_migration");
363
364 let _ = std::fs::remove_dir_all(&temp_dir);
366 }
367
368 #[test]
369 fn test_find_baseline_for_version() {
370 let temp_dir = env::temp_dir().join("pgmt_test_find_baseline_for_version");
371 let _ = std::fs::remove_dir_all(&temp_dir);
372 std::fs::create_dir_all(&temp_dir).unwrap();
373
374 std::fs::write(temp_dir.join("baseline_V1000000000.sql"), "-- Baseline 1").unwrap();
376 std::fs::write(temp_dir.join("baseline_V2000000000.sql"), "-- Baseline 2").unwrap();
377 std::fs::write(temp_dir.join("baseline_V4000000000.sql"), "-- Baseline 4").unwrap();
378
379 let baseline = find_baseline_for_version(&temp_dir, 3000000000)
381 .unwrap()
382 .unwrap();
383 assert_eq!(baseline.version, 2000000000);
384
385 let baseline = find_baseline_for_version(&temp_dir, 1500000000)
387 .unwrap()
388 .unwrap();
389 assert_eq!(baseline.version, 1000000000);
390
391 assert!(
393 find_baseline_for_version(&temp_dir, 500000000)
394 .unwrap()
395 .is_none()
396 );
397
398 let _ = std::fs::remove_dir_all(&temp_dir);
400 }
401}