1use std::path::{Path, PathBuf};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct MigrationFile {
6 pub version: u64,
7 pub width: usize,
8 pub description: String,
9 pub filename: String,
10 pub path: PathBuf,
11}
12
13#[derive(Debug, Clone)]
15pub struct MigrationDir {
16 pub role: String,
17 pub path: PathBuf,
18 pub files: Vec<MigrationFile>,
19}
20
21pub fn parse_filename(name: &str) -> Option<(u64, usize, &str)> {
24 let stem = name.strip_suffix(".sql")?;
25 let digits: String = stem.chars().take_while(|c| c.is_ascii_digit()).collect();
26 if digits.is_empty() {
27 return None;
28 }
29 let rest = &stem[digits.len()..];
30 let desc = rest.strip_prefix('_')?;
31 let version = digits.parse::<u64>().ok()?;
32 Some((version, digits.len(), desc))
33}
34
35pub fn scan_dir(role: &str, path: &Path) -> std::io::Result<MigrationDir> {
37 let mut files = Vec::new();
38 if path.exists() {
39 for entry in std::fs::read_dir(path)? {
40 let entry = entry?;
41 let name = entry.file_name().to_string_lossy().into_owned();
42 if let Some((version, width, desc)) = parse_filename(&name) {
43 files.push(MigrationFile {
44 version,
45 width,
46 description: desc.to_string(),
47 filename: name,
48 path: entry.path(),
49 });
50 }
51 }
52 }
53 files.sort_by_key(|f| f.version);
54 Ok(MigrationDir {
55 role: role.to_string(),
56 path: path.to_path_buf(),
57 files,
58 })
59}
60
61pub mod apply;
62pub mod config;
63pub mod error;
64pub mod git;
65pub mod model;
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70
71 #[test]
72 fn parses_zero_padded() {
73 assert_eq!(
74 parse_filename("0286_captable.sql"),
75 Some((286, 4, "captable"))
76 );
77 }
78
79 #[test]
80 fn parses_multi_underscore_description() {
81 assert_eq!(
82 parse_filename("0012_add_forms_index.sql"),
83 Some((12, 4, "add_forms_index"))
84 );
85 }
86
87 #[test]
88 fn rejects_no_leading_digits() {
89 assert_eq!(parse_filename("captable_0286.sql"), None);
90 }
91
92 #[test]
93 fn rejects_missing_underscore() {
94 assert_eq!(parse_filename("0286captable.sql"), None);
95 }
96
97 #[test]
98 fn rejects_non_sql() {
99 assert_eq!(parse_filename("0286_x.txt"), None);
100 }
101
102 #[test]
103 fn scan_missing_dir_is_empty() {
104 let d = scan_dir("platform", Path::new("/no/such/dir")).unwrap();
105 assert!(d.files.is_empty());
106 }
107
108 #[test]
109 fn scan_reads_and_sorts() {
110 let tmp = tempfile::tempdir().unwrap();
111 for n in ["0002_b.sql", "0001_a.sql", "notes.md"] {
112 std::fs::write(tmp.path().join(n), "").unwrap();
113 }
114 let d = scan_dir("platform", tmp.path()).unwrap();
115 assert_eq!(d.files.len(), 2);
116 assert_eq!(d.files[0].version, 1);
117 assert_eq!(d.files[1].version, 2);
118 }
119}