Skip to main content

migrate_guard/
lib.rs

1use std::path::{Path, PathBuf};
2
3/// One parsed migration file within a numbering namespace.
4#[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/// An independent numbering namespace (platform vs tenant, etc.).
14#[derive(Debug, Clone)]
15pub struct MigrationDir {
16    pub role: String,
17    pub path: PathBuf,
18    pub files: Vec<MigrationFile>,
19}
20
21/// Parse a migration filename: a leading run of ASCII digits, then '_',
22/// then a description, then ".sql". Returns (version, digit_width, desc).
23pub 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
35/// Scan a directory for migration files (missing dir → empty), sorted by version.
36pub 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}