Skip to main content

rust_bucket/
migrations.rs

1// Version migration support
2//
3// Embeds markdown migration files from the migrations/ directory and provides
4// a function to retrieve migrations between two versions.
5
6use rust_embed::RustEmbed;
7use semver::Version;
8use thiserror::Error;
9
10/// Embedded migration files from the migrations/ directory
11#[derive(RustEmbed)]
12#[folder = "migrations/"]
13struct MigrationFiles;
14
15/// A single version migration with instructions
16#[derive(Debug, Clone)]
17pub struct Migration {
18    pub version: Version,
19    pub instructions: String,
20}
21
22/// Errors that can occur when working with migrations
23#[derive(Debug, Error)]
24pub enum MigrationError {
25    #[error("Failed to parse version '{0}': {1}")]
26    VersionParse(String, semver::Error),
27}
28
29/// Returns all migrations between two versions (exclusive of `from`, inclusive of `to`).
30///
31/// Migrations are returned sorted by version in ascending order.
32/// Filenames that don't parse as semver versions are silently skipped.
33///
34/// Returns an empty Vec if `from >= to`.
35pub fn migrations_between(from: &Version, to: &Version) -> Result<Vec<Migration>, MigrationError> {
36    if from >= to {
37        return Ok(Vec::new());
38    }
39
40    let mut migrations = Vec::new();
41
42    for filename in MigrationFiles::iter() {
43        // Strip .md extension to get version string
44        let version_str = match filename.strip_suffix(".md") {
45            Some(v) => v,
46            None => continue,
47        };
48
49        // Parse version, skip files that don't parse
50        let version = match Version::parse(version_str) {
51            Ok(v) => v,
52            Err(_) => continue,
53        };
54
55        // Check if this migration is in range (from < version <= to)
56        if version > *from
57            && version <= *to
58            && let Some(file) = MigrationFiles::get(&filename)
59        {
60            let instructions = String::from_utf8_lossy(&file.data).to_string();
61            migrations.push(Migration {
62                version,
63                instructions,
64            });
65        }
66    }
67
68    // Sort by version ascending
69    migrations.sort_by(|a, b| a.version.cmp(&b.version));
70
71    Ok(migrations)
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn test_migrations_between_includes_060() -> Result<(), Box<dyn std::error::Error>> {
80        let from = Version::new(0, 5, 0);
81        let to = Version::new(0, 6, 0);
82        let result = migrations_between(&from, &to)?;
83        assert_eq!(result.len(), 1);
84        assert_eq!(result[0].version, Version::new(0, 6, 0));
85        Ok(())
86    }
87
88    #[test]
89    fn test_migrations_between_same_version_returns_empty() -> Result<(), Box<dyn std::error::Error>>
90    {
91        let v = Version::new(0, 6, 0);
92        let result = migrations_between(&v, &v)?;
93        assert!(result.is_empty());
94        Ok(())
95    }
96
97    #[test]
98    fn test_migrations_between_reversed_returns_empty() -> Result<(), Box<dyn std::error::Error>> {
99        let from = Version::new(0, 7, 0);
100        let to = Version::new(0, 5, 0);
101        let result = migrations_between(&from, &to)?;
102        assert!(result.is_empty());
103        Ok(())
104    }
105
106    #[test]
107    fn test_migration_content_is_non_empty() -> Result<(), Box<dyn std::error::Error>> {
108        let from = Version::new(0, 5, 0);
109        let to = Version::new(0, 6, 0);
110        let result = migrations_between(&from, &to)?;
111        assert!(!result.is_empty());
112        for migration in &result {
113            assert!(!migration.instructions.is_empty());
114        }
115        Ok(())
116    }
117}