Skip to content

Repository files navigation

rehearse

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.

Why

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.

Install

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"] }

Five-Minute Quickstart

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.

Local Publish Smoke Test

The repository includes a no-server local publish check that simulates registry artifact resolution without writing to user-level Cargo state:

scripts/publish-local.sh

By 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.

Define Operations

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.

Compose A Pipeline

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_write

The 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_vscode

The 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 --execute

The 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 -- --execute

Provide the crates.io token through .env.local without committing it:

export CARGO_REGISTRY_TOKEN=cio_your_crates_io_token_here

Describe

describe() 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

Dry-run uses SafeDryRun by default:

ImpactDefault dry-run action
PureRun
SessionRun
ReadRun
WriteSkip
DeleteSkip
OpaqueDeny

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_write

Its 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

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.

Progress listeners

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;

Graph Output

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 Contract

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.

Manual Builder

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()),);

Limitations

  • #[operation] currently supports async free functions with owned non-context parameters, zero or one #[context] &C parameter, concrete Result<Output, Error> returns, up to eight non-context parameters, and no generics.
  • #[pipeline] currently supports straight-line plan constructors ending in Ok(value), with step-produced values usable only in later step!(...) 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::sync for synchronous work.

Status

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages