diff --git a/Cargo.lock b/Cargo.lock index 8de9030..7077422 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "ductor" -version = "0.1.0" +version = "0.1.1" dependencies = [ "ductor-core", "ductor-macros", @@ -12,11 +12,11 @@ dependencies = [ [[package]] name = "ductor-core" -version = "0.1.0" +version = "0.1.1" [[package]] name = "ductor-macros" -version = "0.1.0" +version = "0.1.1" dependencies = [ "proc-macro2", "quote", diff --git a/README.md b/README.md new file mode 100644 index 0000000..4072920 --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# ductor + +[![crates.io][crates-badge]][crates-url] + +[crates-badge]: https://img.shields.io/crates/v/ductor.svg +[crates-url]: https://crates.io/crates/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: + +```toml +[dependencies] +ductor = "0.1" +``` + +then you define a state machine, a capability, and a unit: + +```rust +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 { + pub state: State, + pub caps: Caps, +} + +// 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. + +```rust +#[typestate] +pub enum Connection { + #[transition(Connected, requires = Tls)] + Disconnected, + Connected { addr: SocketAddr }, +} +``` + +this gives you: +- structs `Disconnected` and `Connected` (with `IsState` impls) +- a family marker `Connection` (implements `StateFamily`) +- a `Transition` impl with `Requirements = 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. + +```rust +#[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: + +```rust +#[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: + +```rust +#[unit(states = (NetworkState, AuthState))] +pub struct MyService { + pub state: S, + pub caps: C, +} +``` + +you use `states!(...)` to create the initial tuple and `transition_at()` to transition just one of them: + +```rust +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. + +```rust +#[spec(for = Connected, with = Tls)] +impl Connection { + pub fn send(&self, data: &[u8]) { /* ... */ } +} + +// `send()` only exists on Connection>: +conn.send(b"hello"); // works +``` + +use `_` or `()` as wildcards for "any state" or "any cap". + +## 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 | + +your struct **must** have `state: State` and `caps: Caps` fields (for now). + +### `#[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 `. + +| example | file | what it shows | +|---------|------|---------------| +| `simple` | `examples/simple.rs` | the bare minimum door lock thing | +| `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 = ...)]` | diff --git a/ductor-core/Cargo.toml b/ductor-core/Cargo.toml index 2f8372f..0d72aff 100644 --- a/ductor-core/Cargo.toml +++ b/ductor-core/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "ductor-core" -version = "0.1.0" +version = "0.1.1" edition = "2024" -description = "ductor" +description = "zero-cost typestate lib: core traits and helpers" license = "MIT OR Apache-2.0" -repository = "https://github.com/bushyice/ductor" +repository = "https://tangled.org/bushyice.com/ductor" [dependencies] diff --git a/ductor-core/src/lib.rs b/ductor-core/src/lib.rs index 346d767..7527c84 100644 --- a/ductor-core/src/lib.rs +++ b/ductor-core/src/lib.rs @@ -1,11 +1,29 @@ +/// TODO: Extend limits beyond 7 + +/// marks something as a capability type. +/// +/// you can use it through `#[cap]` or just implement it manually on a struct. +/// capabilities get attached to a `Unit` and the compiler checks them whenever +/// a transition or spec block requires them. pub trait Capability {} + +/// marker for things that can show up as requirements on a transition. +/// +/// anything that's a `Capability` also counts as a `Requirement`. tuples of +/// requirements (up to 7) work too, and so does `NoRequirement`. pub trait Requirement {} impl Requirement for C {} impl Satisfies for C {} +/// checks whether a caps container satisfies a given requirement. +/// +/// the `M` type param disambiguates which capability in a tuple we're looking +/// at. you don't really need to implement this yourself since the macros and +/// blanket impls handle it all. pub trait Satisfies {} +/// extension trait that helps disambiguate `Satisfies` resolution. pub trait SatisfiesExt {} impl SatisfiesExt for C where @@ -14,10 +32,27 @@ where { } +/// marker type for when a capability satisfies itself directly. pub struct IsSelf; +/// marker type for when no capability is needed (`NoRequirement`). pub struct IsNone; +/// wraps a tuple of markers for multi-capability requirements. pub struct IsTuple(pub M); - +/// marker for proxy capability delegation (`#[cap(as = ...)]`). +pub struct IsAs; +/// per-position proxy markers so impls don't overlap. +pub struct IsAs0; +pub struct IsAs1; +pub struct IsAs2; +pub struct IsAs3; +pub struct IsAs4; +pub struct IsAs5; +pub struct IsAs6; + +/// when there's no requirement at all. +/// +/// transitions without a `requires` clause get this as their +/// `Transition::Requirements` type. everything satisfies it. pub struct NoRequirement; impl Requirement for NoRequirement {} impl Satisfies for C {} @@ -52,6 +87,12 @@ macro_rules! impl_tuple_satisfies { impl Satisfies<(), IsTuple<()>> for C {} +/// a newtype wrapper around a tuple of capabilities. +/// +/// you usually make these with the `caps!()` macro. the type param `C` is +/// the tuple type, like `(NetCap, AuthCap)`. access it through a `Unit` +/// via `.caps()`. +#[derive(Debug, Clone)] pub struct Caps(pub C); impl Satisfies for Caps @@ -61,6 +102,7 @@ where { } +/// pulls out a specific capability by type and marker index. pub trait HasCap { fn get_cap(&self) -> &Target; } @@ -115,6 +157,15 @@ macro_rules! impl_has_cap { ) => {}; } +/// wraps up one or more capability values into a `Caps` tuple. +/// +/// # example +/// ``` +/// use ductor_core::*; +/// struct MyCap; +/// impl Capability for MyCap {} +/// let c = caps!(MyCap); +/// ``` #[macro_export] macro_rules! caps { ($($s:expr),* $(,)?) => { @@ -122,6 +173,18 @@ macro_rules! caps { }; } +/// wraps up one or more state values into a `States` tuple. +/// +/// handy when you're creating a unit with multiple state families. +/// +/// # example +/// ``` +/// use ductor_core::*; +/// #[derive(Debug, Clone)] +/// struct MyState; +/// impl IsState for MyState { type Family = (); } +/// let s = states!(MyState); +/// ``` #[macro_export] macro_rules! states { ($($s:expr),* $(,)?) => { @@ -129,6 +192,10 @@ macro_rules! states { }; } +/// constraint that a `States` tuple follows a given family set. +/// +/// makes sure a unit's state tuple has states from the right families in +/// the right order. generated automatically by `#[unit(states = ...)]`. pub trait Follows {} macro_rules! impl_follows { @@ -145,59 +212,94 @@ macro_rules! impl_follows { }; } -pub trait Provides {} -pub trait Conflicts {} - +/// marker trait for a family of related states. +/// +/// each family is one state machine. generated by `#[typestate]` (the enum +/// itself becomes the family struct) and by `#[unit]` (creates a private +/// `{UnitName}Family` struct). pub trait StateFamily {} +/// links a state struct back to its owning `StateFamily`. +/// +/// generated by `#[typestate]` for each variant of the enum. pub trait IsState { + /// the family this state belongs to. type Family: StateFamily; } +/// declares that a state machine family supports going from `From` to `To`. +/// +/// the `Requirements` associated type says which caps need to be present. +/// generated by `#[transition(Target, requires = ...)]`. pub trait Transition { + /// the capability requirements that must be satisfied. type Requirements: Requirement; } +/// marker trait for state tuples that represent a set of states. pub trait StateSet {} +/// a newtype wrapper around a tuple of state values. +/// +/// each element is a concrete state from a specific `StateFamily`. you +/// usually make these with `states!()`. indexed access comes from +/// `StateAccess`. #[derive(Debug, Clone)] pub struct States(pub S); +/// trait for pulling out a state by type from a tuple-like container. pub trait GetState { fn get(&self) -> &Target; } +/// indexed state extraction from a state tuple. +/// +/// `IndexMarker` tells us which position the target state lives at. pub trait HasState { + /// destructure the tuple to own the state. fn get_state(self) -> Target; + /// borrow the state from the tuple. fn get_state_ref(&self) -> &Target; } +/// family-based state access on a state tuple. +/// +/// lets you grab the state belonging to a specific family, no matter where +/// it is in the tuple. pub trait HasFamily { type State: IsState; fn get_family_state(&self) -> &Self::State; } +/// convenience trait for selecting a state by family value. pub trait SelectFamily { type State: IsState; fn select(&self, _: Family) -> &Self::State; } +/// destructure-and-replace a state value (you probably want `ReplaceStateAt`). pub trait ReplaceState { type Output; fn replace(self, new: To) -> Self::Output; } +/// marker trait for index-level position types (`Is0`, `Is1`, ...). pub trait IndexMarker {} macro_rules! make_idx_marker { ($name:ident) => { + /// index marker for tuple position. pub struct $name; impl IndexMarker for $name {} }; } +/// destructure a state tuple, swap one element by index, and rebuild it. +/// +/// used internally by `transition_at()` to type-safely update a single +/// state in a multi-state unit. pub trait ReplaceStateAt { fn replace_with(self, f: F) -> Out where @@ -217,6 +319,14 @@ where } } +/// convenience trait with `.take()`, `.get()`, and `.select()` on state +/// tuples and single states. +/// +/// - `take::()` -- destructure to own a specific state type. +/// - `get::()` -- borrow a specific state type. +/// - `select::(family)` -- borrow a state by its family. +/// +/// blanket-implemented for all `IsState` and `States<(...)>`. pub trait StateAccess: Sized { #[inline(always)] fn take(self) -> Target @@ -492,22 +602,47 @@ impl_replace_state!([Is0, Is1, Is2, Is3, Is4] => [A0, A1, A2, A3, A4]); impl_replace_state!([Is0, Is1, Is2, Is3, Is4, Is5] => [A0, A1, A2, A3, A4, A5]); impl_replace_state!([Is0, Is1, Is2, Is3, Is4, Is5, Is6] => [A0, A1, A2, A3, A4, A5, A6]); +/// a typed unit with a state and capabilities. +/// +/// this is the main component in ductor. a `Unit` wraps a state (single or +/// tuple) and a set of caps, and makes sure at compile time that +/// transitions and method calls are valid for the current state/cap combo. +/// +/// generated by the `#[unit]` attribute. pub trait Unit { + /// the state type (single state or `States<(...)>` tuple). type State; + /// the capability container type (a `Caps<(...)>` tuple). type Caps; + /// the family for this unit (used in unit-level `#[spec]` blocks). type Family: StateFamily; + /// borrow the current state. fn state(&self) -> &Self::State; + /// borrow the current capabilities. fn caps(&self) -> &Self::Caps; } +/// trait for single-state transitions. +/// +/// the `#[unit]` macro implements this for every state that has a valid +/// `Transition` to `Target`. the `transition()` method only compiles when +/// this trait is satisfied. pub trait CanTransitionTo { + /// the output unit type after the transition. type Out; + /// do the transition. fn perform_transition(self, f: F) -> Self::Out; } +/// trait for multi-state (indexed) transitions. +/// +/// works through the `transition_at()` method on units with tuple states. +/// lets you transition one state machine inside a unit that manages several. pub trait CanTransitionAt { + /// the output unit type after the transition. type Out; + /// do the indexed transition. fn perform_transition(self, f: F) -> Self::Out; } diff --git a/ductor-macros/Cargo.toml b/ductor-macros/Cargo.toml index 4aa8de8..8dfab36 100644 --- a/ductor-macros/Cargo.toml +++ b/ductor-macros/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "ductor-macros" -version = "0.1.0" +version = "0.1.1" edition = "2024" -description = "ductor" +description = "zero-cost typestate lib: procedural macros" license = "MIT OR Apache-2.0" -repository = "https://github.com/bushyice/ductor" +repository = "https://tangled.org/bushyice.com/ductor" [lib] proc-macro = true diff --git a/ductor-macros/src/lib.rs b/ductor-macros/src/lib.rs index 1d5591d..8db3826 100644 --- a/ductor-macros/src/lib.rs +++ b/ductor-macros/src/lib.rs @@ -12,14 +12,20 @@ use syn::{ struct AttrArgs { derives: Vec, states: Option, + as_caps: Option, } impl Parse for AttrArgs { fn parse(input: ParseStream) -> syn::Result { let mut derives = Vec::new(); let mut states = None; + let mut as_caps = None; while !input.is_empty() { - if input.peek(Ident) || input.peek(Token![type]) || input.peek(Token![const]) { + if input.peek(Ident) + || input.peek(Token![type]) + || input.peek(Token![const]) + || input.peek(Token![as]) + { let id = Ident::parse_any(input)?; if id == "derive" { let content; @@ -29,16 +35,12 @@ impl Parse for AttrArgs { derives.extend(paths); } else if id == "states" { input.parse::()?; - let val = if input.peek(syn::token::Paren) { - let content; - parenthesized!(content in input); - let tys: Punctuated = - content.parse_terminated(syn::Type::parse, Token![,])?; - SpecValue::Tuple(tys.into_iter().collect()) - } else { - SpecValue::Single(input.parse()?) - }; + let val = parse_spec_value(input)?; states = Some(val); + } else if id == "as" { + input.parse::()?; + let val = parse_spec_value(input)?; + as_caps = Some(val); } } if input.peek(Token![,]) { @@ -47,7 +49,23 @@ impl Parse for AttrArgs { break; } } - Ok(AttrArgs { derives, states }) + Ok(AttrArgs { + derives, + states, + as_caps, + }) + } +} + +fn parse_spec_value(input: ParseStream) -> syn::Result { + if input.peek(syn::token::Paren) { + let content; + parenthesized!(content in input); + let tys: Punctuated = + content.parse_terminated(syn::Type::parse, Token![,])?; + Ok(SpecValue::Tuple(tys.into_iter().collect())) + } else { + Ok(SpecValue::Single(input.parse()?)) } } @@ -89,6 +107,31 @@ impl Parse for TransitionAttr { } } +/// turns an enum into a state machine. +/// +/// each variant becomes its own struct. the enum name becomes a family struct +/// implementing `StateFamily`. every variant gets an `IsState` impl. +/// +/// if you slap `#[transition(Target, requires = Type)]` on a variant, it +/// generates a `Transition` impl with optional capability requirements. :D +/// +/// # attr arguments +/// - `derive(Trait, ...)`: passes derives through to every generated struct. +/// +/// # variant attrs +/// - `#[transition(Target)]`: marks a valid transition from this state to `Target`. +/// - `#[transition(Target, requires = Type)]`: same but needs a capability. +/// +/// # example +/// ```ignore +/// #[typestate(derive(Debug))] +/// pub enum Door { +/// #[transition(Closed)] +/// Open, +/// #[transition(Open)] +/// Closed, +/// } +/// ``` #[proc_macro_attribute] pub fn typestate(attr: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(attr as AttrArgs); @@ -175,6 +218,29 @@ pub fn typestate(attr: TokenStream, item: TokenStream) -> TokenStream { TokenStream::from(expanded) } +/// generates a typed container (unit) for states and capabilities. +/// +/// give it a struct with `state: State` and `caps: Caps` fields(for now), and it +/// generates: +/// - a `Unit` impl with associated `State`, `Caps`, and `Family` types. +/// - a `new(state, caps)` constructor. +/// - a `transition()` method for single-state units. +/// - a `transition_at()` method for multi-state units. +/// - `CanTransitionTo` impls for every allowed transition. +/// +/// # attr arguments +/// - `derive(Trait, ...)`: passed through to the struct. +/// - `states = Type` or `states = (Type, ...)`: constrains the state tuple +/// via `Follows` so positions match up with state families. +/// +/// # example +/// ```ignore +/// #[unit(derive(Debug))] +/// pub struct Lock { +/// pub state: State, +/// pub caps: Caps, +/// } +/// ``` #[proc_macro_attribute] pub fn unit(attr: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(attr as AttrArgs); @@ -292,6 +358,31 @@ pub fn unit(attr: TokenStream, item: TokenStream) -> TokenStream { TokenStream::from(expanded) } +/// marks a struct as a capability type. +/// +/// implements `Capability` for the struct. passes through any `derive(...)`. +/// +/// with `as = (A, B, ...)` it becomes a **parent capability**: having it +/// in your `Caps` tuple satisfies requirement bounds for `A`, `B`, etc. at +/// compile time. so you don't need to list all the little ones individually :D +/// +/// # attr arguments +/// - `derive(Trait, ...)`: passes derives through. +/// - `as = Type` or `as = (Type, ...)`: proxy targets this cap satisfies. +/// +/// # example +/// ```ignore +/// #[cap(derive(Debug, Clone))] +/// pub struct Read; +/// +/// #[cap(derive(Debug, Clone))] +/// pub struct Write; +/// +/// #[cap(as = (Read, Write))] +/// pub struct Admin; +/// +/// // caps!(Admin) now satisfies both Read and Write requirements. +/// ``` #[proc_macro_attribute] pub fn cap(attr: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(attr as AttrArgs); @@ -305,13 +396,93 @@ pub fn cap(attr: TokenStream, item: TokenStream) -> TokenStream { } let name = &input.ident; - quote! { + let mut output = quote! { #input impl ductor::Capability for #name {} + }; + + if let Some(ref as_caps) = args.as_caps { + let targets: Vec<&syn::Type> = match as_caps { + SpecValue::Single(ty) => vec![ty], + SpecValue::Tuple(tys) => tys.iter().collect(), + }; + for target in &targets { + let proxy_impls = gen_as_proxy_impls(name, target); + output.extend(proxy_impls); + } } - .into() + + TokenStream::from(output) +} + +fn gen_as_proxy_impls(name: &Ident, target: &syn::Type) -> proc_macro2::TokenStream { + let max_size: usize = 7; + let as_markers: &[Ident] = &[ + format_ident!("IsAs0"), + format_ident!("IsAs1"), + format_ident!("IsAs2"), + format_ident!("IsAs3"), + format_ident!("IsAs4"), + format_ident!("IsAs5"), + format_ident!("IsAs6"), + ]; + + let mut impls = proc_macro2::TokenStream::new(); + + for size in 1..=max_size { + for pos in 0..size { + let before: Vec = (0..pos).map(|i| format_ident!("T{i}")).collect(); + let after: Vec = (pos + 1..size).map(|i| format_ident!("T{i}")).collect(); + + let tuple_types: Vec<_> = { + let mut types = Vec::new(); + for b in &before { + types.push(quote! { #b }); + } + types.push(quote! { #name }); + for a in &after { + types.push(quote! { #a }); + } + types + }; + + let marker = &as_markers[pos]; + impls.extend(quote! { + impl<#(#before,)* #(#after,)*> + ductor::Satisfies<#target, ductor::#marker> + for ductor::Caps<(#(#tuple_types,)*)> + {} + }); + } + } + + impls } +/// attaches an `impl` block to a specific state/capability combo. +/// +/// the `for` param says which state (or state tuple) the methods show up for. +/// the `with` param specifies capability requirements. use `_` or `()` as a +/// wildcard for "any state" or "any cap". +/// +/// methods with `#[transit]` inside a `#[spec]` block get their return types +/// rewritten to reflect the new state and caps. +/// +/// # attr arguments +/// - `for = StateType` -- single-state spec. +/// - `for = (A, B, ...)` -- multi-state spec. +/// - `with = CapType` -- single-cap requirement. +/// - `with = (A, B, ...)` -- multi-cap requirement. +/// - use `_` or `()` as a wildcard. +/// +/// # example +/// ```ignore +/// #[spec(for = Connected, with = NetCap)] +/// impl Network { +/// #[transit(to = Disconnected)] +/// pub fn disconnect(self) { ... } +/// } +/// ``` #[proc_macro_attribute] pub fn spec(attr: TokenStream, item: TokenStream) -> TokenStream { let args = match syn::parse::(attr) { diff --git a/ductor/Cargo.toml b/ductor/Cargo.toml index ee27826..3d102ac 100644 --- a/ductor/Cargo.toml +++ b/ductor/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "ductor" -version = "0.1.0" +version = "0.1.1" edition = "2024" -description = "ductor" +description = "zero-cost typestate lib" license = "MIT OR Apache-2.0" -repository = "https://github.com/bushyice/ductor" +repository = "https://tangled.org/bushyice.com/ductor" +readme = "../README.md" [dependencies] -ductor-core = { version = "0.1.0", path = "../ductor-core" } -ductor-macros = { version = "0.1.0", path = "../ductor-macros" } +ductor-core = { version = "0.1.1", path = "../ductor-core" } +ductor-macros = { version = "0.1.1", path = "../ductor-macros" } diff --git a/ductor/examples/base.rs b/ductor/examples/base.rs index 11845c8..f93bae3 100644 --- a/ductor/examples/base.rs +++ b/ductor/examples/base.rs @@ -37,14 +37,19 @@ pub enum AuthState { }, } +// `states = ConnectionState` says the State generic must be a single state +// from the ConnectionState family. #[unit(derive(Debug, Clone), states = ConnectionState)] pub struct Network { pub state: S, pub caps: C, } +// methods available when Network is Disconnected and we've got NetCap. #[spec(for = Disconnected, with = NetCap)] impl Network { + // `#[transit(to = Connected)]` rewrites the return type so the caller + // sees the new state in the type system. #[transit(to = Connected)] pub fn connect(self, addr: &str) { self.transition(|_| Connected { @@ -53,20 +58,26 @@ impl Network { } } +// `states = (NetworkState, AuthState)` means position 0 is from NetworkState +// family and position 1 from AuthState family. #[unit(derive(Debug, Clone), states = (NetworkState, AuthState))] pub struct MyService { pub state: S, pub caps: C, } +// spec: NetworkDown, any AuthState, needs NetCap. +// `()` is a wildcard for "any state". #[spec(for = (NetworkDown, ()), with = NetCap)] impl MyService { + // `to = (NetworkUp, ())` -- `()` wildcard means keep the existing auth state. #[transit(to = (NetworkUp, ()))] pub fn up(self, network: Network) { self.transition_at(|_| NetworkUp { network }) } } +// spec: NetworkUp + any AuthState, no cap needed. #[spec(for = (NetworkUp, ()))] impl MyService { pub fn get_addr(&self) -> SocketAddr { @@ -80,14 +91,17 @@ impl MyService { } } +// spec: NetworkUp + Guest, needs both NetCap and AuthCap. #[spec(for = (NetworkUp, Guest), with = (NetCap, AuthCap))] impl MyService { + // `to = ((), Authenticated)` -- `()` keeps NetworkUp unchanged. #[transit(to = ((), Authenticated))] pub fn authenticate(self, user: impl Into) { self.transition_at(|_| Authenticated { user: user.into() }) } } +// spec: NetworkUp + Authenticated, needs AuthCap. #[spec(for = (NetworkUp, Authenticated), with = AuthCap)] impl MyService { pub fn get_user(&self) -> &String { @@ -95,6 +109,8 @@ impl MyService { } } +// spec: NetworkUp + Authenticated, needs NetCap. +// multiple `#[spec]` blocks for the same state is totally fine. #[spec(for = (NetworkUp, Authenticated), with = NetCap)] impl MyService { pub fn ping(&self) { @@ -105,6 +121,7 @@ impl MyService { fn main() { let caps = caps!(NetCap, AuthCap); + // build a chain: create service -> bring up network -> authenticate. let service = MyService::new(states!(NetworkDown, Guest), caps) .up(Network::new(Disconnected, NetCap).connect("127.0.0.1:8080")) .authenticate("user"); diff --git a/ductor/examples/branching.rs b/ductor/examples/branching.rs new file mode 100644 index 0000000..30291e6 --- /dev/null +++ b/ductor/examples/branching.rs @@ -0,0 +1,193 @@ +//! one-to-many transitions where a single state branches to multiple targets. +//! +//! ascii flow: +//! +//! ┌──────────┐ +//! │ Pending │ +//! └────┬─────┘ +//! ┌────┴──────┐ +//! ┌────▼────┐ ┌────▼─────┐ +//! │Confirmed│ │Cancelled │ +//! └────┬────┘ └──────────┘ +//! ┌────┴─────┐ +//! ┌────▼───┐ ┌────▼───┐ +//! │Shipped │ │Returned│ +//! └───┬────┘ └────────┘ +//! ┌────┴──────┐ +//! ┌────▼────┐ ┌────▼───┐ +//! │Delivered│ │ Lost │ +//! └─────────┘ └────────┘ + +#![allow(unused)] + +use ductor::*; + +// caps + +#[cap(derive(Debug, Clone))] +pub struct PaymentCap; + +#[cap(derive(Debug, Clone))] +pub struct AdminCap; + +// order state machine + +#[typestate(derive(Debug, Clone))] +pub enum OrderState { + // pending can go to confirmed (needs payment) OR cancelled (no caps). + #[transition(Confirmed, requires = PaymentCap)] + #[transition(Cancelled)] + Pending { + item: String, + }, + + // confirmed can go to shipped OR cancelled (needs admin override). + #[transition(Shipped)] + #[transition(Cancelled, requires = AdminCap)] + Confirmed { + item: String, + }, + + // shipped can go to delivered OR lost. + #[transition(Delivered)] + #[transition(Lost)] + Shipped { + item: String, + tracking: String, + }, + + // terminal states, no way out + Cancelled { + item: String, + }, + Delivered { + item: String, + }, + Lost { + item: String, + }, +} + +// the unit + +#[unit(derive(Debug, Clone), states = OrderState)] +pub struct Order { + pub state: S, + pub caps: C, +} + +// spec'd methods + +#[spec(for = Pending, with = PaymentCap)] +impl Order { + #[transit(to = Confirmed)] + pub fn confirm(self) { + self.transition(|Pending { item }| Confirmed { item }) + } +} + +// cancelling from pending doesn't need any caps, so no `with =`. +#[spec(for = Pending)] +impl Order { + #[transit(to = Cancelled)] + pub fn cancel(self) { + self.transition(|Pending { item }| Cancelled { item }) + } +} + +#[spec(for = Confirmed)] +impl Order { + #[transit(to = Shipped)] + pub fn ship(self, tracking: &str) { + self.transition(|Confirmed { item }| Shipped { + item, + tracking: tracking.to_string(), + }) + } +} + +#[spec(for = Confirmed, with = AdminCap)] +impl Order { + #[transit(to = Cancelled)] + pub fn cancel_with_admin(self) { + self.transition(|Confirmed { item }| Cancelled { item }) + } +} + +#[spec(for = Shipped)] +impl Order { + #[transit(to = Delivered)] + pub fn deliver(self) { + self.transition(|Shipped { item, .. }| Delivered { item }) + } + + #[transit(to = Lost)] + pub fn report_lost(self) { + self.transition(|Shipped { item, .. }| Lost { item }) + } +} + +fn main() { + // branch 1: pending -> confirmed -> shipped -> delivered + let caps = caps!(PaymentCap, AdminCap); + let delivered = Order::new( + Pending { + item: "Laptop".into(), + }, + caps, + ) + .confirm() // pending -> confirmed (needs PaymentCap) + .ship("TRACK-123") + .deliver(); // shipped -> delivered + + println!("Delivered: {delivered:?}"); + + // branch 2: pending -> cancelled (no cap needed at all) + let caps = caps!(); + let cancelled = Order::new( + Pending { + item: "Mouse".into(), + }, + caps, + ) + .cancel(); + + println!("Cancelled: {cancelled:?}"); + + // branch 3: confirmed -> cancelled (needs AdminCap) + let caps = caps!(PaymentCap, AdminCap); + let cancelled2 = Order::new( + Pending { + item: "Keyboard".into(), + }, + caps, + ) + .confirm() + .cancel_with_admin(); + + println!("Admin cancelled: {cancelled2:?}"); + + // branch 4: shipped -> lost + let caps = caps!(PaymentCap, AdminCap); + let lost = Order::new( + Pending { + item: "Monitor".into(), + }, + caps, + ) + .confirm() + .ship("TRACK-456") + .report_lost(); + + println!("Lost: {lost:?}"); + + // things that won't compile (uncomment to watch it fail): + // .confirm() without PaymentCap: + // Order::new(Pending { item: "X" }, caps!()).confirm(); + // ^^^^ missing PaymentCap + + // .cancel_with_admin() without AdminCap on a confirmed order: + // Order::new(Pending { item: "X" }, caps!(PaymentCap)) + // .confirm().cancel_with_admin(); + // ^^^^ missing AdminCap +} diff --git a/ductor/examples/capabilities.rs b/ductor/examples/capabilities.rs new file mode 100644 index 0000000..00ee6ee --- /dev/null +++ b/ductor/examples/capabilities.rs @@ -0,0 +1,59 @@ +use ductor::*; + +#[cap(derive(Debug, Clone, Copy))] +pub struct ReadCap; + +#[cap(derive(Debug, Clone, Copy))] +pub struct WriteCap; + +#[cap(derive(Debug, Clone, Copy))] +pub struct AdminCap; + +#[typestate(derive(Debug, Clone))] +pub enum DocState { + #[transition(Open, requires = (ReadCap, WriteCap))] + Closed, + + #[transition(Open)] + Open { content: String }, +} + +#[unit(derive(Debug, Clone), states = DocState)] +pub struct Doc { + pub state: S, + pub caps: C, +} + +#[spec(for = Open)] +impl Doc { + pub fn read(&self) -> &str { + &self.state.select(DocState).content + } +} + +#[spec(for = Open, with = WriteCap)] +impl Doc { + pub fn write(self, new_content: &str) -> Self { + self.transition(|Open { .. }| Open { + content: new_content.to_string(), + }) + } +} + +#[spec(for = Open, with = AdminCap)] +impl Doc { + pub fn get_metadata(&self) -> &'static str { + "size: 42, owner: admin" + } +} + +fn main() { + let caps = caps!(ReadCap, WriteCap); + let doc = Doc::new(Closed, caps) + .transition(|Closed| Open { + content: String::new(), + }) + .write("Hello, capabilities!"); + + println!("Content: {}", doc.read()); +} diff --git a/ductor/examples/multi_unit.rs b/ductor/examples/multi_unit.rs new file mode 100644 index 0000000..d2b721a --- /dev/null +++ b/ductor/examples/multi_unit.rs @@ -0,0 +1,114 @@ +use ductor::*; + +#[cap(derive(Debug, Clone, Copy))] +pub struct IgnitionKey; + +#[cap(derive(Debug, Clone, Copy))] +pub struct Fuel; + +#[typestate(derive(Debug, Clone, Copy))] +pub enum EngineState { + // needs both key and fuel to start + #[transition(Starting, requires = (IgnitionKey, Fuel))] + Stopped, + + #[transition(Running)] + Starting, + + #[transition(Stopped)] + Running, +} + +#[unit(derive(Debug, Clone), states = EngineState)] +pub struct Engine { + pub state: S, + pub caps: C, +} + +#[spec(for = Stopped, with = (IgnitionKey, Fuel))] +impl Engine { + #[transit(to = Starting)] + pub fn start(self) { + self.transition(|Stopped| Starting) + } +} + +#[spec(for = Starting)] +impl Engine { + #[transit(to = Running)] + pub fn rev(self) { + self.transition(|Starting| Running) + } +} + +#[typestate(derive(Debug, Clone, Copy))] +pub enum TransmissionState { + #[transition(FirstGear)] + Neutral, + + #[transition(SecondGear)] + FirstGear, + + #[transition(ThirdGear)] + SecondGear, + + #[transition(SecondGear)] + ThirdGear, +} + +#[unit(derive(Debug, Clone), states = TransmissionState)] +pub struct Transmission { + pub state: S, + pub caps: C, +} + +#[spec(for = Neutral)] +impl Transmission { + #[transit(to = FirstGear)] + pub fn shift_up(self) { + self.transition(|Neutral| FirstGear) + } +} + +// - vehicle: combines engine + transmission + +#[unit(derive(Debug, Clone), states = (EngineState, TransmissionState))] +pub struct Vehicle { + pub state: S, + pub caps: C, +} + +#[spec(for = (Stopped, Neutral), with = (IgnitionKey, Fuel))] +impl Vehicle { + #[transit(to = (Starting, ()))] + pub fn start_engine(self) { + self.transition_at(|Stopped| Starting) + } +} + +#[spec(for = (Starting, ()))] +impl Vehicle { + #[transit(to = (Running, ()))] + pub fn rev_engine(self) { + self.transition_at(|Starting| Running) + } +} + +#[spec(for = (Running, Neutral))] +impl Vehicle { + #[transit(to = ((), FirstGear))] + pub fn shift_to_first(self) { + self.transition_at(|Neutral| FirstGear) + } +} + +fn main() { + let caps = caps!(IgnitionKey, Fuel); + + let vehicle = Vehicle::new(states!(Stopped, Neutral), caps) + .start_engine() + .rev_engine() + .shift_to_first(); + + println!("Vehicle state: {vehicle:?}"); +} diff --git a/ductor/examples/proxy_cap.rs b/ductor/examples/proxy_cap.rs new file mode 100644 index 0000000..ef15c2c --- /dev/null +++ b/ductor/examples/proxy_cap.rs @@ -0,0 +1,105 @@ +use ductor::*; + +// individual caps + +#[cap(derive(Debug, Clone))] +pub struct Read; + +#[cap(derive(Debug, Clone))] +pub struct Write; + +#[cap(derive(Debug, Clone))] +pub struct Delete; + +// parent cap: delegates to Read + Write + Delete +// +// `Admin` satisfies requirement bounds for Read, Write, and Delete. +// just `caps!(Admin)` is enough now. + +#[cap(as = (Read, Write, Delete))] +pub struct Admin; + +// state machine: +// +// Closed ──(needs Read)──▶ Open +// ▲ │ +// │ ┌─────────────┤ +// │ (needs Delete) │ (needs Write) +// │ ▼ ▼ +// └───────Close◀─────────┘ + +#[typestate(derive(Debug, Clone))] +pub enum FileState { + #[transition(Open, requires = Read)] + Closed, + + #[transition(Close, requires = Write)] + #[transition(Open)] + Open { content: String }, + + #[transition(Open, requires = Delete)] + Close { content: String }, +} + +#[unit(derive(Debug, Clone), states = FileState)] +pub struct File { + pub state: S, + pub caps: C, +} + +#[spec(for = Closed, with = Read)] +impl File { + #[transit(to = Open)] + pub fn open(self, content: &str) -> Self { + self.transition(|Closed| Open { + content: content.to_string(), + }) + } +} + +#[spec(for = Open)] +impl File { + pub fn read(&self) -> &str { + &self.state.select(FileState).content + } +} + +#[spec(for = Open, with = Write)] +impl File { + #[transit(to = Close)] + pub fn close(self) -> Self { + self.transition(|Open { content }| Close { content }) + } +} + +#[spec(for = Close, with = Delete)] +impl File { + #[transit(to = Open)] + pub fn reopen(self, extra: &str) -> Self { + self.transition(|Close { mut content }| { + content.push_str(extra); + Open { content } + }) + } +} + +fn main() { + // using each caps + let caps = caps!(Read, Write, Delete); + let file = File::new(Closed, caps) + .open("hello") + .close() + .reopen(" world"); + + println!("Individual caps: {}", file.read()); + + // using Admin cap instead of Read + Write + Delete + // Admin proxies all three, so this compiles fine without them individually. + let admin_caps = caps!(Admin); + let file = File::new(Closed, admin_caps) + .open("admin says") + .close() + .reopen(" proxy works"); + + println!("Admin caps: {}", file.read()); +} diff --git a/ductor/examples/simple.rs b/ductor/examples/simple.rs new file mode 100644 index 0000000..187dabf --- /dev/null +++ b/ductor/examples/simple.rs @@ -0,0 +1,29 @@ +use ductor::*; + +#[typestate(derive(Debug))] +pub enum Door { + #[transition(Closed)] + Open, + + #[transition(Open)] + Closed, +} + +#[cap(derive(Debug))] +pub struct HasKey; + +// a unit is a generic container for a state + capabilities. +// must have `state: State` and `caps: Caps` fields. +#[unit(derive(Debug))] +pub struct Lock { + pub state: State, + pub caps: Caps, +} + +fn main() { + let lock = Lock::new(Closed, HasKey) + .transition(|Closed| Open) + .transition(|Open| Closed); + + println!("Lock: {lock:?}"); +} diff --git a/ductor/examples/state_data.rs b/ductor/examples/state_data.rs new file mode 100644 index 0000000..dfc5f1c --- /dev/null +++ b/ductor/examples/state_data.rs @@ -0,0 +1,107 @@ +use ductor::*; +use std::fmt; + +#[typestate(derive(Debug, Clone))] +pub enum DocumentState { + // nothing to see here, move along + #[transition(Drafted)] + Blank, + + // now we've got actual content + #[transition(Reviewed)] + Drafted { title: String, body: String }, + + // reviewer had some thoughts + #[transition(Published)] + Reviewed { + title: String, + body: String, + reviewer_note: Option, + }, + + // terminal state, can't go anywhere else + Published { + title: String, + body: String, + reviewer_note: Option, + }, +} + +#[cap(derive(Debug, Clone))] +pub struct ReviewerRights; + +#[unit(derive(Debug, Clone), states = DocumentState)] +pub struct Document { + pub state: S, + pub caps: C, +} + +#[spec(for = Blank)] +impl Document { + #[transit(to = Drafted)] + pub fn draft(self, title: &str, body: &str) { + // `|_|` ignores the Blank state (it's a unit struct, no fields) + self.transition(|_| Drafted { + title: title.to_string(), + body: body.to_string(), + }) + } +} + +#[spec(for = Drafted, with = ReviewerRights)] +impl Document { + #[transit(to = Reviewed)] + pub fn review(self, note: Option<&str>) { + self.transition(|Drafted { title, body }| Reviewed { + title, + body, + reviewer_note: note.map(String::from), + }) + } +} + +#[spec(for = Reviewed)] +impl Document { + #[transit(to = Published)] + pub fn publish(self) { + self.transition( + |Reviewed { + title, + body, + reviewer_note, + }| Published { + title, + body, + reviewer_note, + }, + ) + } +} + +// Display impl only for published docs. you can't print a draft by +// accident because the method doesn't even exist on those types :D +impl fmt::Display for Document> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let published: &Published = self.state.select(DocumentState); + write!( + f, + "---\ntitle: {}\nbody: {}\n", + published.title, published.body + )?; + if let Some(ref note) = published.reviewer_note { + write!(f, "reviewer note: {note}\n")?; + } + write!(f, "---") + } +} + +fn main() { + let caps = caps!(ReviewerRights); + + let doc = Document::new(Blank, caps) + .draft("Hello!", "This is a state machine with data.") + .review(Some("Looks good to me!")) + .publish(); + + println!("{doc}"); +} diff --git a/ductor/src/lib.rs b/ductor/src/lib.rs index c3e754a..657d93f 100644 --- a/ductor/src/lib.rs +++ b/ductor/src/lib.rs @@ -1,2 +1,75 @@ +//! # ductor +//! +//! so this is ductor: a zero-cost rust typestate library for modeling stateful +//! "units" with capability-driven transitions that get checked at compile time. +//! so here's the deal: you describe your state machine as an enum, declare +//! what transitions are valid (and what capabilities they need), and the compiler +//! handles the rest. invalid transitions just don't compile +//! +//! ## quick start +//! +//! ```rust +//! use ductor::*; +//! +//! // step 1: define a state machine as an enum. +//! // each variant becomes a struct, the enum name becomes the family marker. +//! #[typestate(derive(Debug))] +//! pub enum Door { +//! #[transition(Closed)] +//! Open, +//! #[transition(Open)] +//! Closed, +//! } +//! +//! // step 2: define a capability (optional, only if transitions need caps). +//! #[cap(derive(Debug))] +//! pub struct HasKey; +//! +//! // step 3: define a unit, the typed container that holds state + caps. +//! #[unit(derive(Debug))] +//! pub struct Lock { +//! pub state: State, +//! pub caps: Caps, +//! } +//! +//! // step 4: go wild. wrong transitions won't compile. +//! let lock = Lock::new(Closed, HasKey) +//! .transition(|Closed| Open) +//! .transition(|Open| Closed); +//! # let _ = lock; +//! ``` +//! +//! ## what's here +//! +//! | attribute | what it does | +//! |---|---| +//! | `#[typestate]` | turns an enum into a state machine | +//! | `#[unit]` | creates a typed container for states + caps | +//! | `#[cap]` | marks a struct as a capability | +//! | `#[spec]` | attaches methods to specific state/cap combos | +//! | `#[transit]` | marks a method inside `#[spec]` as doing a transition | +//! +//! ## the big ideas +//! +//! - **state / statefamily**: a state machine is an enum (the family). each +//! variant becomes a state struct that can carry data. +//! - **unit**: a generic struct with `state: State` and `caps: Caps`. the +//! macros generate impls so transitions and cap checks happen at the type level. +//! - **capability**: a marker type a unit carries. transitions and `#[spec]` +//! blocks can require caps then the compiler checks them all. +//! - **tuple states**: one unit can manage multiple state machines at once +//! using `States<(A, B, ...)>`. use `transition_at()` to transition one by one. +//! +//! ## examples +//! +//! check the `examples/` directory: +//! - `simple` - tiny door lock, minimal setup +//! - `base` - multi-state network service with the whole kitchen sink +//! - `state_data` - states with data fields flowing through transitions +//! - `capabilities` - gating operations behind capability checks +//! - `multi_unit` - composing multiple units together +//! - `branching` - one state branching to many targets +//! - `proxy_cap` - parent caps that proxy for their children + pub use ductor_core::*; pub use ductor_macros::*;