statum 0.1.8

Compile-time state machine magic for Rust: Zero-boilerplate typestate patterns with automatic transition validation
Documentation

statum

A zero-boilerplate library for finite-state machines in Rust, with compile-time state transition validation.

Overview

The typestate pattern lets you encode state machines at the type level, making invalid state transitions impossible at compile time. This crate makes implementing typestates effortless through two attributes:

  • #[state] - Define your states and their associated data
  • #[context] - Create your state machine

Installation

Add this to your Cargo.toml:

[dependencies]
statum = "0.1.8"

Quick Start

Here's a simple example of a document workflow:

use statum::{state, context};

#[state]
pub enum DocumentState {
    Draft,                      // A simple state with no data
    Review(ReviewData),         // A state with associated data
    Published,
}

// Data associated with the Review state
struct ReviewData {
    reviewer: String,
    comments: Vec<String>,
}

#[context]
struct Document<S: DocumentState> {
    id: String,
    content: String,
}

impl Document<Draft> {
    fn submit_for_review(self, reviewer: String) -> Document<Review> {
        let review_data = ReviewData {
            reviewer,
            comments: vec![],
        };
        // Use into_context_with() when transitioning to a state with data
        self.into_context_with(review_data)
    }
}

impl Document<Review> {
    fn approve(self) -> Document<Published> {
        // Use into_context() when transitioning to a state without data
        self.into_context()
    }
}

fn main() {
    let doc = Document::new("doc-1".to_owned(), "Hello".to_owned())
        .submit_for_review("Alice".to_owned())
        .approve();
}

Features

Flexible State Definitions

States can be simple markers or carry data specific to that state:

#[state]
pub enum ProcessState {
    // Simple states without data
    Ready,
    Complete,
    
    // States with associated data
    Working(WorkProgress),
    Failed(ErrorInfo),
}

struct WorkProgress {
    started_at: DateTime<Utc>,
    percent_complete: f32,
}

struct ErrorInfo {
    error: String,
    retry_count: u32,
}

Type-Safe State Transitions

The library provides two methods for state transitions:

  • into_context() - For transitioning to states without data
  • into_context_with(data) - For transitioning to states with associated data

This ensures you can't forget to provide required state data:

impl Process<Ready> {
    fn start(self) -> Process<Working> {
        let progress = WorkProgress {
            started_at: Utc::now(),
            percent_complete: 0.0,
        };
        // Must use into_context_with() because Working carries data
        self.into_context_with(progress)
    }
}

impl Process<Working> {
    fn complete(self) -> Process<Complete> {
        // Can use into_context() because Complete has no data
        self.into_context()
    }
    
    fn fail(self, error: String) -> Process<Failed> {
        let error_info = ErrorInfo {
            error,
            retry_count: 0,
        };
        self.into_context_with(error_info)
    }
}

Automatic Constructor Generation

The #[context] attribute automatically generates a new constructor:

#[context]
struct ApiClient<S: ProcessState> {
    client: reqwest::Client,
    base_url: String,
}

// Generated automatically:
impl<S: ProcessState> ApiClient<S> {
    fn new(client: reqwest::Client, base_url: String) -> Self {
        Self {
            client,
            base_url,
            marker: PhantomData,
        }
    }
}

Rich Context

Your state machine can maintain any context it needs:

#[context]
struct RichContext<S: ProcessState> {
    id: Uuid,
    created_at: DateTime<Utc>,
    metadata: HashMap<String, String>,
    config: Config,
}

Real World Example

Here's a more complete example showing async operations and state transitions with data:

use statum::{state, context};
use anyhow::Result;

#[state]
pub enum PublishState {
    Draft,
    Review(ReviewMetadata),
    Published(PublishInfo),
    Archived,
}

struct ReviewMetadata {
    reviewer: String,
    deadline: DateTime<Utc>,
}

struct PublishInfo {
    published_at: DateTime<Utc>,
    published_by: String,
}

#[context]
struct Article<S: PublishState> {
    id: Uuid,
    content: String,
    client: ApiClient,
}

impl Article<Draft> {
    async fn submit_for_review(self, reviewer: String) -> Result<Article<Review>> {
        self.client.save_draft(&self.id, &self.content).await?;
        
        let metadata = ReviewMetadata {
            reviewer,
            deadline: Utc::now() + Duration::days(7),
        };
        Ok(self.into_context_with(metadata))
    }
}

impl Article<Review> {
    async fn approve(self, approver: String) -> Result<Article<Published>> {
        self.client.publish(&self.id).await?;
        
        let publish_info = PublishInfo {
            published_at: Utc::now(),
            published_by: approver,
        };
        Ok(self.into_context_with(publish_info))
    }
    
    async fn request_changes(self) -> Result<Article<Draft>> {
        self.client.reject(&self.id).await?;
        Ok(self.into_context())
    }
}

impl Article<Published> {
    async fn archive(self) -> Result<Article<Archived>> {
        self.client.archive(&self.id).await?;
        Ok(self.into_context())
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    let article = Article::new(
        Uuid::new_v4(),
        "My Article".to_string(),
        ApiClient::new().await,
    );
    
    let published = article
        .submit_for_review("reviewer@example.com".to_owned()).await?
        .approve("editor@example.com".to_owned()).await?;
        
    Ok(())
}

Contributing

Contributions welcome! Feel free to submit pull requests.

License

MIT License - see LICENSE for details.