ductor #
so, ductor is a zero-cost rust typestate library for modeling stateful "units" with capability-driven transitions that get checked at compile time.
the idea is you use proc macros to describe what transitions are valid and what capabilities they need, and then the compiler rejects any invalid stuff before it even runs. no runtime panics from being in the wrong state, no missing-capability bugs... that kind of thing
ductus (latin) -- to lead, to conduct, to guide. i thought it fit.
quick start #
add ductor to your cargo.toml:
[dependencies]
ductor = "0.1"
then you define a state machine, a capability, and a unit:
use ductor::*;
#[typestate(derive(Debug))]
pub enum Door {
#[transition(Closed)]
Open,
#[transition(Open)]
Closed,
}
#[cap(derive(Debug))]
pub struct HasKey;
#[unit(derive(Debug))]
pub struct Lock;
// try adding a transition that doesn't exist, watch it fail
let lock = Lock::new(Closed, HasKey)
.transition(|Closed| Open)
.transition(|Open| Closed);
ok but what's the point #
the whole idea is compile-time state machines. you describe your states and transitions once, and the compiler tracks what state everything is in. methods only show up when you're in the right state, transitions only work when you have the right capabilities. it's all types, zero cost.
typestate pattern #
each state is its own type. you can only move between them if the transition is declared. ductor generates all the boilerplate from a simple enum.
#[typestate]
pub enum Connection {
#[transition(Connected, requires = Tls)]
Disconnected,
Connected { addr: SocketAddr },
}
this gives you:
- structs
DisconnectedandConnected(withIsStateimpls) - a family marker
Connection(implementsStateFamily) - a
Transition<Disconnected, Connected>impl withRequirements = Tls
try calling .transition(|Disconnected| ...) with a target like Listening and the compiler just says no. it's pretty satisfying honestly
capabilities #
capabilities are little marker types your unit carries around. transitions and #[spec] blocks can declare what they need, and the compiler checks everything.
#[cap(derive(Debug))]
pub struct AdminPrivilege;
// this only compiles if your caps include AdminPrivilege:
let area = SecureArea::new(Restricted, caps!(AdminPrivilege))
.transition(|Restricted| Open);
you can also require multiple caps at once with a tuple:
#[transition(Open, requires = (ReadCap, WriteCap))]
ReadOnly,
and with #[cap(as = (A, B, C))] you can have parent caps that proxy for others. so caps!(Admin) can satisfy requirements for Read, Write, and Delete all at once. neat huh? 😃
tuple states #
a single unit can manage multiple independent state machines at the same time using tuple states:
#[unit(states = (NetworkState, AuthState))]
pub struct MyService;
you use states!(...) to create the initial tuple and transition_at() to transition just one of them:
let svc = MyService::new(states!(NetworkDown, Guest), caps)
.up(network) // NetworkDown -> NetworkUp
.authenticate("user"); // Guest -> Authenticated
spec blocks #
#[spec] blocks let you attach methods to specific state (and capability) combos. methods just don't exist when you're not in the right state, the compiler just hides them.
#[spec(for = Connected, with = Tls)]
impl Connection {
pub fn send(&self, data: &[u8]) { /* ... */ }
}
// `send()` only exists on Connection<Connected, Caps<(Tls,)>>:
conn.send(b"hello"); // works
use _ or () as wildcards for "any state" or "any cap".
typestate erasure (unknown states) #
sometimes you need to put units with different states into a collection, pass them through a generic API, or hold onto one without knowing its exact variant. Of<F> wraps any state belonging to family F behind a Box<dyn Any>, erasing the concrete type while remembering the family.
use ductor::*;
// erase a single-state unit:
let erased: Lock<Of<Door>, Caps<(HasKey,)>> = lock.into_unknown();
// check / borrow / recover at runtime:
assert!(erased.is::<Closed>());
let _: Option<&Closed> = erased.as_some::<Closed>();
let back: Option<Lock<Closed, Caps<(HasKey,)>>> = erased.into_some();
methods on units with Of<F> state:
| method | what it does |
|---|---|
is::<S>() |
checks whether the erased state is S |
as_some::<S>() |
borrows as Option<&S> |
into_some::<S>() |
consumes the unit, returns Option<Unit<S, Caps>> |
trim_unknown::<S>() |
alias for into_some |
erasing a single component in a tuple-state unit:
// States<(LoggedIn, Connected)> -> States<(Of<Auth>, Connected)>
let svc = svc.into_unknown_at::<LoggedIn, _, _>();
let erased: Service<States<(Of<Auth>, Connected)>, _> = svc;
// check at a position:
assert!(svc.is_at::<LoggedIn, _>());
// recover at a position:
let svc: Service<States<(LoggedIn, Connected)>, _> = svc.trim_unknown_at::<LoggedIn, _, _>().unwrap();
creating an Of<F> directly:
let erased: Of<Door> = Of::new(Closed);
assert!(erased.is::<Closed>());
erasing everything (any state, even tuples, into the unit's own private family):
let totally_erased: Lock<Of<LockFamily>, _> = lock.into_unknown_all();
macro reference #
#[typestate] #
defines a state machine from an enum. each variant becomes a struct.
| argument | description |
|---|---|
derive(Trait, ...) |
forwards derives to all generated structs |
variant attributes:
| attribute | description |
|---|---|
#[transition(Target)] |
declares a valid transition to Target |
#[transition(Target, requires = Type)] |
same but requires a capability |
#[cap] #
marks a struct as a capability type. with as = (A, B, ...) it acts as a parent cap, having it satisfies requirement bounds for a, b, etc.
| argument | description |
|---|---|
derive(Trait, ...) |
forwards derives |
as = Type or as = (Type, ...) |
proxy targets this cap satisfies |
#[unit] #
generates a typed container with new(), transition(), and transition_at().
| argument | description |
|---|---|
derive(Trait, ...) |
forwards derives |
states = Type |
constrains the state generic to a single family |
states = (A, B, ...) |
constrains to multiple families |
the macro adds state: State and caps: Caps fields + generics automatically. if your struct has extra fields annotate how they're initialized:
| annotation | behavior |
|---|---|
#[unit(default)] |
Default::default() (this is the default if omitted) |
#[unit(take)] |
added as a parameter to new() |
#[unit(construct = expr)] |
computed via the given expression (can reference state, caps, and earlier fields) |
#[unit(derive(Debug))]
pub struct Service {
#[unit(default)]
url: String,
#[unit(take)]
port: u16,
#[unit(construct = format!("{url}:{port}"))]
addr: String,
}
#[spec] #
attaches methods to a specific state/capability combo.
| argument | description |
|---|---|
for = Type |
state type (or tuple) this applies to |
for = (A, B, ...) |
tuple state spec; _ or () = wildcard |
with = Type |
required capability |
with = (A, B, ...) |
multiple required caps |
#[transit] #
used inside #[spec] blocks to mark a method as a state transition. rewrites the return type to reflect the new state/caps.
| argument | description |
|---|---|
to = Type |
target state (or tuple; _/() = wildcard) |
with = Type |
target caps (optional) |
examples #
run any with cargo run --example <name>.
| example | file | what it shows |
|---|---|---|
simple |
examples/simple.rs |
the bare minimum door lock thing |
raw_example |
examples/raw_example.rs |
same as simple but without any macros, all traits impl'd by hand |
base |
examples/base.rs |
multi-state network service with spec blocks, the works |
state_data |
examples/state_data.rs |
states that carry actual data fields through a workflow |
capabilities |
examples/capabilities.rs |
gating operations behind capabilities, compile-time checks |
multi_unit |
examples/multi_unit.rs |
composing multiple units into a bigger state machine |
branching |
examples/branching.rs |
one-to-many transitions from a single source state |
proxy_cap |
examples/proxy_cap.rs |
parent capability delegation with #[cap(as = ...)] |
unknown_state |
examples/unknown_state.rs |
type erasure with Of<>, .into_unknown(), .is_at(), .trim_unknown_at() |
field_inits |
examples/field_inits.rs |
custom fields inside of units |