Skip to content

Wizard

A multi-step interactive form. Each step runs one or more inquire prompts against a state value the wizard owns, and returns a StepOutcome that says whether to go forwards or backwards.

Wizard<S>

pub struct Wizard<S> { /* private fields */ }

impl<S: Send + 'static> Wizard<S> {
    #[must_use]
    pub fn builder() -> WizardBuilder<S>;

    pub async fn run(self) -> Result<S, WizardError>;
}

S is your own state type. The wizard owns it: builder().initial(s) moves it in, and run moves it back out on success. There is no way to borrow the state while the wizard is running.

S: Send + 'static is required. A state type holding a non-Send value (an Rc, a raw pointer) will not compile.

Wizard::run return values

run is async and needs an executor. The crate does not start one — tokio is a dev-dependency only, used by the test suite.

Situation Result
Every step returned Next Ok(state) with all mutations applied
The wizard has zero steps Ok(state) — the initial state, unmodified, without prompting
A step returned Back while on the first step Err(WizardError::Cancelled)
A step returned Err(InquireError::OperationCanceled) while on the first step Err(WizardError::Cancelled)
Any step returned Err(InquireError::OperationInterrupted) Err(WizardError::Interrupted)
Any step returned any other InquireError Err(WizardError::Step { step, message })

run never panics.

The driver loop holds an index into the step list and moves it by one.

  • Ok(StepOutcome::Next) advances the index. If that takes it past the last step, run returns Ok(state).
  • Ok(StepOutcome::Back) decrements the index. On index 0 it returns WizardError::Cancelled instead.
  • Err(InquireError::OperationCanceled) — what inquire returns when the user presses Esc — is handled by the same match arm as Back. Escape and an explicit Back are indistinguishable to the wizard.
  • Err(InquireError::OperationInterrupted)Ctrl+C — returns WizardError::Interrupted from any position, including the first step.
  • Every other InquireError variant is wrapped as WizardError::Step { step, message }, where step is the failing step's name() and message is the underlying error's Display output.

There is no jump-to-step, no skip, and no conditional branching. Back navigation moves exactly one step. See What rtb-tui does not do.

How state threads through the steps

Each step receives &mut S, so step N+1 observes everything step N wrote. Backing up does not roll anything back: the earlier step re-runs against the state as it stands now, including values written by later steps.

Two consequences worth planning for:

  • A step must be safe to run more than once. Writing state.name = Some(prompt()?) is fine; pushing onto a Vec is not, because a back navigation will push a second time.
  • Using the current state to pre-fill the prompt is the intended pattern — that is how a user sees their previous answer when they come back to change it.

WizardStep<S>

#[async_trait]
pub trait WizardStep<S>: Send + Sync {
    fn name(&self) -> &'static str;

    async fn prompt(&self, state: &mut S) -> Result<StepOutcome, InquireError>;
}

name must return a &'static str. It appears in WizardError::Step { step, .. } and nowhere else — it is not shown to the user during the wizard, so make it something you would want in an error message and in a log line.

prompt takes &self, not &mut self. Per-step mutable data has to live in the wizard state or behind interior mutability.

The Send + Sync supertrait bound is on the trait itself, so a step type holding a Cell or an Rc will not compile.

Implementations are boxed: WizardBuilder::step requires W: WizardStep<S> + 'static.

InquireError re-export

The crate re-exports inquire::InquireError from its root:

pub use inquire::InquireError;

That exists so a WizardStep implementation can ?-propagate an inquire prompt without adding inquire to its own Cargo.toml. If you do add inquire directly, keep the version aligned with the one rtb-tui depends on — a mismatch produces two distinct InquireError types and a confusing type error at the ?.

StepOutcome

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepOutcome {
    Next,
    Back,
}

Copy, so returning it never moves anything. There is no Finish variant: a wizard ends when its last step returns Next.

WizardBuilder<S>

pub struct WizardBuilder<S> { /* private fields */ }

impl<S: Send + 'static> WizardBuilder<S> {
    #[must_use] pub fn initial(self, state: S) -> Self;
    #[must_use] pub fn step<W>(self, step: W) -> Self where W: WizardStep<S> + 'static;
    #[must_use] pub fn build(self) -> Wizard<S>;
}
Method Default if not called Notes
initial none — build panics Calling it twice keeps the last value
step no steps; run returns the initial state Steps run in call order
build Consumes the builder

WizardBuilder::build panics without initial

thread 'main' panicked at src/wizard.rs:
Wizard::builder requires .initial(...)

This is a runtime panic, not a compile error. The builder is a plain accumulator, not a typestate builder — nothing in the type system stops you calling build() on a builder that has no initial state. Construct the wizard in one chained expression and the mistake is hard to make:

let wizard = Wizard::<Profile>::builder()
    .initial(Profile::default())
    .step(AskName)
    .build();

The crate's rustdoc claims this contract is "enforced via a test". It is not — see Known documentation defects.