Skip to main content

use_db_migration/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Migration metadata primitives for `RustUse`.
5
6use core::fmt;
7use std::error::Error;
8
9macro_rules! migration_text_type {
10    ($type_name:ident) => {
11        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12        pub struct $type_name(String);
13
14        impl $type_name {
15            /// Creates a migration metadata label.
16            ///
17            /// # Errors
18            ///
19            /// Returns [`MigrationError`] when the label is empty or contains control characters.
20            pub fn new(input: impl AsRef<str>) -> Result<Self, MigrationError> {
21                validate_text(input.as_ref()).map(|value| Self(value.to_owned()))
22            }
23
24            /// Returns the stored label.
25            #[must_use]
26            pub fn as_str(&self) -> &str {
27                &self.0
28            }
29        }
30
31        impl fmt::Display for $type_name {
32            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33                formatter.write_str(self.as_str())
34            }
35        }
36    };
37}
38
39migration_text_type!(MigrationId);
40migration_text_type!(MigrationVersion);
41migration_text_type!(MigrationChecksum);
42migration_text_type!(MigrationAppliedAt);
43
44/// Migration status metadata.
45#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
46pub enum MigrationStatus {
47    /// Migration is pending.
48    #[default]
49    Pending,
50    /// Migration is applied.
51    Applied,
52    /// Migration failed.
53    Failed,
54    /// Migration was reverted.
55    Reverted,
56    /// Status is unknown.
57    Unknown,
58}
59
60/// Migration direction metadata.
61#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
62pub enum MigrationDirection {
63    /// Apply migration direction.
64    #[default]
65    Up,
66    /// Revert migration direction.
67    Down,
68}
69
70/// A migration step descriptor.
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct MigrationStep {
73    id: MigrationId,
74    version: Option<MigrationVersion>,
75    checksum: Option<MigrationChecksum>,
76}
77
78impl MigrationStep {
79    /// Creates a migration step.
80    #[must_use]
81    pub const fn new(id: MigrationId) -> Self {
82        Self {
83            id,
84            version: None,
85            checksum: None,
86        }
87    }
88
89    /// Adds a migration version.
90    #[must_use]
91    pub fn with_version(mut self, version: MigrationVersion) -> Self {
92        self.version = Some(version);
93        self
94    }
95
96    /// Adds a migration checksum.
97    #[must_use]
98    pub fn with_checksum(mut self, checksum: MigrationChecksum) -> Self {
99        self.checksum = Some(checksum);
100        self
101    }
102
103    /// Returns the migration id.
104    #[must_use]
105    pub const fn id(&self) -> &MigrationId {
106        &self.id
107    }
108}
109
110/// A migration plan descriptor. This does not execute migrations.
111#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct MigrationPlan {
113    direction: MigrationDirection,
114    steps: Vec<MigrationStep>,
115}
116
117impl MigrationPlan {
118    /// Creates a migration plan.
119    #[must_use]
120    pub const fn new(direction: MigrationDirection, steps: Vec<MigrationStep>) -> Self {
121        Self { direction, steps }
122    }
123
124    /// Returns the migration direction.
125    #[must_use]
126    pub const fn direction(&self) -> MigrationDirection {
127        self.direction
128    }
129
130    /// Returns the migration steps.
131    #[must_use]
132    pub fn steps(&self) -> &[MigrationStep] {
133        &self.steps
134    }
135}
136
137/// Error returned by migration metadata constructors.
138#[derive(Clone, Copy, Debug, Eq, PartialEq)]
139pub enum MigrationError {
140    /// Label was empty.
141    Empty,
142    /// Label contained a control character.
143    ControlCharacter,
144}
145
146impl fmt::Display for MigrationError {
147    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148        match self {
149            Self::Empty => formatter.write_str("migration label cannot be empty"),
150            Self::ControlCharacter => {
151                formatter.write_str("migration label cannot contain control characters")
152            },
153        }
154    }
155}
156
157impl Error for MigrationError {}
158
159fn validate_text(input: &str) -> Result<&str, MigrationError> {
160    if input.chars().any(char::is_control) {
161        return Err(MigrationError::ControlCharacter);
162    }
163    let trimmed = input.trim();
164    if trimmed.is_empty() {
165        return Err(MigrationError::Empty);
166    }
167    Ok(trimmed)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::{
173        MigrationDirection, MigrationId, MigrationPlan, MigrationStatus, MigrationStep,
174        MigrationVersion,
175    };
176
177    #[test]
178    fn stores_migration_metadata() -> Result<(), Box<dyn std::error::Error>> {
179        let step = MigrationStep::new(MigrationId::new("create-users")?)
180            .with_version(MigrationVersion::new("202605250001")?);
181        let plan = MigrationPlan::new(MigrationDirection::Up, vec![step]);
182
183        assert_eq!(plan.direction(), MigrationDirection::Up);
184        assert_eq!(plan.steps()[0].id().as_str(), "create-users");
185        assert_eq!(MigrationStatus::default(), MigrationStatus::Pending);
186        Ok(())
187    }
188}