Build typed operation plans in Rust, then inspect, rehearse, or execute them.
rehearse is an effect-aware operation planning library. Users explicitly
declare operation impact, compose operations into an ordered plan, and then pick
an interpretation:
- describe the static plan;
- dry-run safe work while skipping or denying mutations;
- execute the full plan with fail-fast semantics.
The library does not infer whether arbitrary Rust code mutates state. Impact is metadata supplied by operation authors.
Deployment, migration, and administrative tools often need three related workflows: show what would happen, perform safe validation, and execute the real change. Without a plan abstraction, those modes tend to become scattered conditionals around direct service calls.
rehearse keeps the meaningful actions explicit. A pipeline builds a plan of
declared operations, and runners decide how each operation behaves.
Use the released crate from crates.io:
[dependencies]
rehearse = "0.2.0"For local checkout development, use rehearse = { path = "crates/rehearse" }.
Release notes live in CHANGELOG.md. The maintainer publish runbook lives in RELEASE.md.
Enable structured serialization for descriptions, reports, and public status types with:
[dependencies]
rehearse = { version = "0.2.0", features = ["serde"] }Define operation bodies, compose them into a plan, then choose how to interpret the plan:
use rehearse::{operation, pipeline,Plan};#[derive(Clone)]structServices;#[derive(Debug)]structError;impl std::fmt::DisplayforError{fnfmt(&self,f:&mut std::fmt::Formatter<'_>) -> std::fmt::Result{
f.write_str("operation failed")}}impl std::error::ErrorforError{}#[operation(impact = read)]asyncfnread_version(#[context]_services:&Services) -> Result<String,Error>{Ok("current".to_owned())}#[operation(impact = write)]asyncfndeploy(version:String) -> Result<String,Error>{Ok(format!("deployed {version}"))}#[pipeline]fnrelease(version:String) -> Plan<Services,String,Error>{let _current = rehearse::step!(read_version())?;let result = rehearse::step!(deploy(version))?;Ok(result)}#[tokio::main]asyncfnmain() -> Result<(),Box<dyn std::error::Error>>{let services = Services;let plan = release("v2".to_owned());println!("{}", plan.describe());let report = plan.dry_run(&services).await;println!("{report}");// Use execute only when you intend to run write/delete operations.let _output = plan.execute(&services).await?;Ok(())}Calling release(...) builds the plan only. read_version and deploy run
only through dry_run or execute, and the default dry-run policy skips the
write.
The repository includes a no-server local publish check that simulates registry artifact resolution without writing to user-level Cargo state:
scripts/publish-local.shBy default it recreates target/local-registry, packages both crates, writes a
git-backed Cargo registry index and local .crate downloads, then compiles a
throwaway consumer crate with:
rehearse = { version = "0.2.0", registry = "rehearse-local", features = ["serde"] }Expected final output includes the generated .crate paths and:
consumer checked successfully using registry 'rehearse-local'
Use LOCAL_REGISTRY_DIR=/path/to/registry scripts/publish-local.sh to choose a
different generated registry location.
The #[operation] macro turns an async function into an operation constructor.
The original body becomes delayed executor code; calling the constructor only
records metadata and inputs.
use rehearse::operation;#[derive(Clone)]structServices;#[derive(Debug)]structDeployError;#[derive(Clone)]structSession;#[derive(Clone)]structDeployment;#[operation(impact = session)]asyncfnlogin(#[context]services:&Services,credentials:String,) -> Result<Session,DeployError>{let _ = (services, credentials);Ok(Session)}#[operation(impact = write)]asyncfnapply_changes(#[context]services:&Services,session:Session,) -> Result<Deployment,DeployError>{let _ = (services, session);Ok(Deployment)}Operation inputs and outputs currently require Clone + Send + Sync + 'static.
Operation constructors currently accept up to eight non-context inputs.
All operations in a plan share one context type and one plan error type.
The #[pipeline] macro lowers straight-line step!(...) calls into an ordered
static plan. Calling the function builds a plan; it does not run operation
bodies.
use rehearse::{pipeline,Plan};#[pipeline]fndeploy(credentials:String) -> Plan<Services,Deployment,DeployError>{let session = rehearse::step!(login(credentials))?;let deployment = rehearse::step!(apply_changes(session))?;Ok(deployment)}let plan = deploy("secret".to_owned());For a complete macro-based local example covering describe, dry-run, and execute:
cargo run -p rehearse --example read_after_writeThe configure_vscode example uses a #[pipeline] plan to add missing
rust-analyzer settings to .vscode/settings.json:
cargo run -p rehearse --example configure_vscode -- --dry-run
cargo run -p rehearse --example configure_vscodeThe conditional_rollout example uses seeded random conditions and progress
listeners to rehearse or execute a simulated feature rollout:
cargo run -p rehearse --example conditional_rollout -- --seed 7
cargo run -p rehearse --example conditional_rollout -- --seed 7 --executeThe deploy example is this repository's guarded crates.io publish workflow.
By default it describes the publish plan and runs safe dry-run checks; real
cargo publish uploads require --execute.
cargo run -p rehearse --example deploy
cargo run -p rehearse --example deploy -- --executeProvide the crates.io token through .env.local without committing it:
export CARGO_REGISTRY_TOKEN=cio_your_crates_io_token_heredescribe() renders static plan metadata and the default dry-run action for
each node. It does not contact services or resolve values.
println!("{}", plan.describe());Example output:
deploy
1 login session run
2 apply_changes write skip
Use describe_with_policy(&policy) to render actions for a custom
DryRunPolicy.
Use describe_execution() before execute mode when the dry-run action column
would be misleading. It renders the same static plan order without an action
column:
deploy
1 login session
2 apply_changes write
Dry-run uses SafeDryRun by default:
| Impact | Default dry-run action |
|---|---|
Pure | Run |
Session | Run |
Read | Run |
Write | Skip |
Delete | Skip |
Opaque | Deny |
Skipped, denied, failed, or blocked operations produce no value. Later nodes can still run when they do not depend on unavailable values.
The crate includes a compiled example:
cargo run -p rehearse --example read_after_writeIts dry-run output demonstrates the central read-after-write case:
[ok] login executed
[ok] read_current executed
[ok] calculate_changes executed
[skip] apply_changes skipped: write operation
[ok] read_account_quota executed
[block] verify_deployment blocked: missing #3 (apply_changes)
[skip] delete_old_releases skipped: delete operation
Dry-run incomplete: 4 executed, 2 skipped, 0 denied, 1 blocked, 0 failed.
read_account_quota runs even though it appears after the skipped write because
it has no value dependency on that write. verify_deployment is blocked because
it needs the unavailable Deployment output from apply_changes.
Use report.require_no_failures()? when skipped writes are acceptable but
executed operation failures are not. Use report.require_complete()? when dry-run
should be treated as successful only if every node executed.
Execute mode runs every operation in plan order and stops on the first operation failure.
let deployment = plan.execute(&services).await?;Execute mode never applies dry-run policy.
Use listener variants when a CLI or automation runner wants live progress while preserving the same semantics:
use rehearse::{ProgressEvent,ProgressListener};structLogger;impl<E>ProgressListener<E>forLogger{fnon_event(&mutself,event:ProgressEvent<'_,E>){ifletProgressEvent::NodeStarted{ node, .. } = event {println!("starting {}", node.name());}}}letmut logger = Logger;let report = plan.dry_run_with_listener(&services,&mut logger).await;let deployment = plan.execute_with_listener(&services,&mut logger).await?;let description = plan.describe_with_listener(&mut logger);Listeners also work with custom dry-run policies through
describe_with_policy_and_listener and dry_run_with_policy_and_listener.
They observe node order, impact, selected dry-run actions, node outcomes, and
plan completion. They do not change policy decisions, dependency checks, value
storage, or operation execution.
For simple CLIs, ConsoleProgress provides a ready-to-use stdout listener:
use rehearse::ConsoleProgress;letmut progress = ConsoleProgress::new();let report = plan.dry_run_with_listener(&services,&mut progress).await;Use to_mermaid() when a CLI, README, or issue needs a visual dependency graph:
println!("{}", plan.to_mermaid());The graph is static. It shows plan nodes and explicit Value<T> dependencies;
it does not inspect operation bodies.
Dry-run may authenticate, observe external state, perform local computation, and invoke explicitly non-persisting validation. It must not intentionally commit writes or deletes to managed domain state.
This is a declaration-based contract, not a proof of non-mutation. It depends on correct impact classification and on user-supplied operation bodies honoring their declared role.
The macros are a frontend over the manual runtime API. Tests and lower-level integrations may still build plans directly.
use rehearse::{Input,PlanBuilder};letmut builder = PlanBuilder::<Services,DeployError>::new("deploy");let session = builder.add(login("secret".to_owned()));let deployment = builder.add(apply_changes(Input::value(session)));let plan = builder.finish(deployment);Synchronous work can use Operation::sync without manually boxing a future:
use rehearse::{Impact,Operation,OperationMetadata};let read = Operation::sync(OperationMetadata::new("read_config",Impact::Read),(),
|_services:&Services,()| Ok::<_,DeployError>("config".to_owned()),);#[operation]currently supports async free functions with owned non-context parameters, zero or one#[context] &Cparameter, concreteResult<Output, Error>returns, up to eight non-context parameters, and no generics.#[pipeline]currently supports straight-line plan constructors ending inOk(value), with step-produced values usable only in laterstep!(...)calls or the final output.- No preview hooks or predicted values.
- No runtime branching, loops over operation outputs, retries, rollback, durable execution, or serialization.
- No automatic mutation detection.
- Operation inputs and outputs must be owned cloneable values.
- Dry-run and execute currently use the same context type.
- The operation macro is async-only; use the manual API's
Operation::syncfor synchronous work.
The current crate includes ordered plans, execute, dry-run, reports, static
describe output, Mermaid graph output, #[operation], #[pipeline], step!,
compiled examples, API docs, optional serde support, Apache-2.0 packaging
metadata, local publish smoke testing, and a guarded crates.io publish workflow
example.