use-db-migration 0.1.0

Primitive database migration metadata for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

//! Migration metadata primitives for `RustUse`.

use core::fmt;
use std::error::Error;

macro_rules! migration_text_type {
    ($type_name:ident) => {
        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
        pub struct $type_name(String);

        impl $type_name {
            /// Creates a migration metadata label.
            ///
            /// # Errors
            ///
            /// Returns [`MigrationError`] when the label is empty or contains control characters.
            pub fn new(input: impl AsRef<str>) -> Result<Self, MigrationError> {
                validate_text(input.as_ref()).map(|value| Self(value.to_owned()))
            }

            /// Returns the stored label.
            #[must_use]
            pub fn as_str(&self) -> &str {
                &self.0
            }
        }

        impl fmt::Display for $type_name {
            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
                formatter.write_str(self.as_str())
            }
        }
    };
}

migration_text_type!(MigrationId);
migration_text_type!(MigrationVersion);
migration_text_type!(MigrationChecksum);
migration_text_type!(MigrationAppliedAt);

/// Migration status metadata.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum MigrationStatus {
    /// Migration is pending.
    #[default]
    Pending,
    /// Migration is applied.
    Applied,
    /// Migration failed.
    Failed,
    /// Migration was reverted.
    Reverted,
    /// Status is unknown.
    Unknown,
}

/// Migration direction metadata.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum MigrationDirection {
    /// Apply migration direction.
    #[default]
    Up,
    /// Revert migration direction.
    Down,
}

/// A migration step descriptor.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MigrationStep {
    id: MigrationId,
    version: Option<MigrationVersion>,
    checksum: Option<MigrationChecksum>,
}

impl MigrationStep {
    /// Creates a migration step.
    #[must_use]
    pub const fn new(id: MigrationId) -> Self {
        Self {
            id,
            version: None,
            checksum: None,
        }
    }

    /// Adds a migration version.
    #[must_use]
    pub fn with_version(mut self, version: MigrationVersion) -> Self {
        self.version = Some(version);
        self
    }

    /// Adds a migration checksum.
    #[must_use]
    pub fn with_checksum(mut self, checksum: MigrationChecksum) -> Self {
        self.checksum = Some(checksum);
        self
    }

    /// Returns the migration id.
    #[must_use]
    pub const fn id(&self) -> &MigrationId {
        &self.id
    }
}

/// A migration plan descriptor. This does not execute migrations.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MigrationPlan {
    direction: MigrationDirection,
    steps: Vec<MigrationStep>,
}

impl MigrationPlan {
    /// Creates a migration plan.
    #[must_use]
    pub const fn new(direction: MigrationDirection, steps: Vec<MigrationStep>) -> Self {
        Self { direction, steps }
    }

    /// Returns the migration direction.
    #[must_use]
    pub const fn direction(&self) -> MigrationDirection {
        self.direction
    }

    /// Returns the migration steps.
    #[must_use]
    pub fn steps(&self) -> &[MigrationStep] {
        &self.steps
    }
}

/// Error returned by migration metadata constructors.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MigrationError {
    /// Label was empty.
    Empty,
    /// Label contained a control character.
    ControlCharacter,
}

impl fmt::Display for MigrationError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("migration label cannot be empty"),
            Self::ControlCharacter => {
                formatter.write_str("migration label cannot contain control characters")
            },
        }
    }
}

impl Error for MigrationError {}

fn validate_text(input: &str) -> Result<&str, MigrationError> {
    if input.chars().any(char::is_control) {
        return Err(MigrationError::ControlCharacter);
    }
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err(MigrationError::Empty);
    }
    Ok(trimmed)
}

#[cfg(test)]
mod tests {
    use super::{
        MigrationDirection, MigrationId, MigrationPlan, MigrationStatus, MigrationStep,
        MigrationVersion,
    };

    #[test]
    fn stores_migration_metadata() -> Result<(), Box<dyn std::error::Error>> {
        let step = MigrationStep::new(MigrationId::new("create-users")?)
            .with_version(MigrationVersion::new("202605250001")?);
        let plan = MigrationPlan::new(MigrationDirection::Up, vec![step]);

        assert_eq!(plan.direction(), MigrationDirection::Up);
        assert_eq!(plan.steps()[0].id().as_str(), "create-users");
        assert_eq!(MigrationStatus::default(), MigrationStatus::Pending);
        Ok(())
    }
}