statum-core 0.1.17

Compile-time state machine magic for Rust: Zero-boilerplate typestate patterns with automatic transition validation
Documentation
statum-core-0.1.17 has been yanked.

Statum

Statum is a zero-boilerplate library for finite-state machines in Rust, with compile-time state transition validation. It provides two attribute macros:

  • #[state] for defining states (as enums).
  • #[machine] for creating a state machine struct that tracks which state you’re in at compile time.

Quick Start (Minimal Example)

Here’s the simplest usage of Statum without any extra features:

use statum::{state, machine};

// 1. Define your states as an enum.
#[state]
pub enum LightState {
    Off,
    On,
}

// 2. Create a machine struct that references one of those states.
#[machine]
pub struct Light<S: LightState> {
    name: String,
}

// 3. Implement transitions for each state.
impl Light<Off> {
    pub fn switch_on(self) -> Light<On> {
        self.transition()
    }
}

impl Light<On> {
    pub fn switch_off(self) -> Light<Off> {
        self.transition()
    }
}

fn main() {
    // 4. Create a machine with the "Off" state.
    let light = Light::<Off>::new("desk lamp".to_owned());

    // 5. Transition from Off -> On, On -> Off, etc.
    let light = light.switch_on();
    let light = light.switch_off();
}

How It Works

  • #[state] transforms your enum, generating one struct per variant (like Off and On), plus a trait LightState.
  • #[machine] injects extra fields (marker, state_data) to track which state you’re in, letting you define transitions that change the state at the type level.

That’s it! You now have a compile-time guaranteed state machine where invalid transitions are impossible.


Additional Features & Examples

1. Adding Debug, Clone, or Other Derives

By default, you can add normal Rust derives on your enum and struct. For example:

#[state]
#[derive(Debug, Clone)]
pub enum LightState {
    Off,
    On,
}

#[machine]
#[derive(Debug, Clone)]
pub struct Light<S: LightState> {
    name: String,
}

Important: If you place #[derive(...)] above #[machine], you may see an error like:

error[E0063]: missing fields `marker` and `state_data` in initializer of `Light<_>`
   |
14 | #[derive(Debug, Clone)]
   |          ^ missing `marker` and `state_data`

That’s because the derive macro for Clone, Debug, etc., expands before #[machine] has injected these extra fields. To avoid this, either:

  • Put #[machine] above the derive(s), or
  • Remove the conflicting derive(s) from the same item.

For example, this works:

#[machine]
#[derive(Debug, Clone)]
pub struct Light<S: LightState> {
    name: String,
}

2. serde Integration

Statum can optionally propagate Serialize/Deserialize derives if you enable the "serde" feature and derive those on your #[state] enum. For example:

[dependencies]
statum = { version = "x.y.z", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }

Then, in your code:

use statum::state;

#[state]
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub enum DocumentState {
    Draft,
    Published,
}

If you enable Statum’s "serde" feature, any #[derive(Serialize)] and #[derive(Deserialize)] you put on the enum will get passed through to the expanded variant structs. If you do not enable that feature, deriving those traits will likely fail to compile.


3. Complex Transitions & Data-Bearing States

States can hold data. For example:

#[state]
pub enum ReviewState {
    Draft,
    InReview(ReviewData),
    Published,
}

#[derive(Debug)]
pub struct ReviewData {
    reviewer: String,
    notes: Vec<String>,
}

#[machine]
pub struct Document<S: ReviewState> {
    id: String,
    content: String,
}

// ...

impl Document<Draft> {
    pub fn submit_for_review(self, reviewer: String) -> Document<InReview> {
        let data = ReviewData { reviewer, notes: vec![] };
        self.transition_with(data)
    }
}

// ...

Accessing State Data

Use .get_state_data() or .get_state_data_mut() to interact with the state-specific data:

impl Document<Review> {
    fn add_comment(&mut self, comment: String) {
        if let Some(review_data) = self.get_state_data_mut() {
            review_data.comments.push(comment);
        }
    }

    fn reviewer_name(&self) -> Option<&str> {
        self.get_state_data().map(|data| data.reviewer.as_str())
    }

    fn approve(self) -> Document<Published> {
        self.transition()
    }
}

4. Attribute Ordering

  • #[state] must go on an enum.
  • #[machine] must go on a struct.
  • Because #[machine] injects extra fields, you need it above any user #[derive(...)]. If you place #[derive(...) ] first, you might see “missing fields marker and state_data in initializer” errors.

5. Database Integration

statum provides a seamless way to convert database entries into state machines using a declarative macro-based approach. By combining #[model], #[state], and #[machine] attributes, it simplifies state validation and transition logic for Rust applications.

  • Declarative syntax for linking database models to state machines.
  • Automated generation of try_to_* methods for state validation and transition.
  • Flexible validation logic using user-defined is_* methods.
  • Type-safe and ergonomic state machine transitions.
  • Clear error handling via StatumError.

Defining Your State Machine

Define your state machine states using the #[state] and #[machine] macros:

#[state]
#[derive(Clone, Debug, Serialize)]
pub enum TaskState {
    New,
    InProgress(DraftData),
    Complete,
}

#[derive(Clone, Debug, Serialize)]
pub struct DraftData {
    version: u32,
}

#[machine]
#[derive(Clone, Debug, Serialize)]
struct TaskMachine<S: TaskState> {
    client: String,
}

Linking Database Models with #[model]

Use the #[model] attribute to link your database representation to the state machine:

#[model(state = TaskState, machine = TaskMachine)]
#[derive(Clone)]
struct DbData {
    id: String,
    state: String,
}

Writing Validators

Define custom is_* methods for each state variant to validate transitions:

impl DbData {
    fn is_new(&self) -> bool {
        self.state == "new"
    }

    fn is_in_progress(&self) -> bool {
        self.state == "in_progress"
    }

    fn is_complete(&self) -> bool {
        self.state == "complete"
    }
}

These methods provide fine-grained control over the conditions that must be met for each state.

Generated try_to_* Methods

The #[model] macro automatically generates try_to_* methods for each state variant:

impl DbData {
    pub fn try_to_new(&self, client: String) -> Result<TaskMachine<New>, StatumError> {
        if self.is_new() {
            Ok(TaskMachine::new(client))
        } else {
            Err(StatumError::InvalidState)
        }
    }

    pub fn try_to_in_progress(&self, client: String) -> Result<TaskMachine<InProgress>, StatumError> {
        if self.is_in_progress() {
            Ok(TaskMachine::new(client))
        } else {
            Err(StatumError::InvalidState)
        }
    }

    pub fn try_to_complete(&self, client: String) -> Result<TaskMachine<Complete>, StatumError> {
        if self.is_complete() {
            Ok(TaskMachine::new(client))
        } else {
            Err(StatumError::InvalidState)
        }
    }
}

These methods are:

  • Type-safe: Ensure only valid transitions are allowed.
  • Easy to use: Automatically wired to the custom validators we made earlier.

Example workflow

fn main() {
    let client = "mock_client".to_owned();
    let task = DbData {
        id: "42".to_owned(),
        state: "new".to_owned(),
    };

    if let Ok(machine) = task.try_to_new(client.clone()) {
        let machine = machine.start();
        let machine = machine.process();
    } else if let Ok(machine) = task.clone().try_to_in_progress(client.clone()) {
        let machine = machine.process();
    }
}
  • Start with a DbData object representing a database entry.
  • Use try_to_* methods to validate and transition to the appropriate state.
  • Call state-specific methods (start, process, etc.) on the machine.

Error Handling

All try_to_* methods return a Result with:

  • Ok: A valid TaskMachine instance in the desired state.
  • Err(StatumError): An error indicating invalid state transitions.

Common Errors and Tips

  1. missing fields marker and state_data

    • Usually means your derive macros (e.g., Clone or Debug) expanded before Statum could inject those fields. Move #[machine] above your derives, or remove them.
  2. cannot find type X in this scope

    • Ensure that you define your #[machine] struct before you reference it in impl blocks or function calls.
  3. Feature gating

    • If you’re using #[derive(Serialize, Deserialize)] on a #[state] enum but didn’t enable the serde feature in Statum, you’ll get compile errors about missing trait bounds.

Lint Warnings (unexpected_cfgs)

If you see warnings like:

= note: no expected values for `feature`
= help: consider adding `foo` as a feature in `Cargo.toml`

it means you have the unexpected_cfgs lint enabled but you haven’t told your crate “feature = foo” is valid. This is a Rust nightly lint that ensures you only use #[cfg(feature="...")] with known feature values.

To fix it, either disable the lint or declare the allowed values in your crate’s Cargo.toml:

[lints.rust.unexpected_cfgs]
check-cfg = [
  'cfg(feature, values("serde"))'
]
level = "warn"

License

Statum is distributed under the terms of the MIT license. See LICENSE for details.