rtb_tui/wizard.rs
1//! [`Wizard`] and [`WizardStep`] — multi-step interactive form.
2
3use async_trait::async_trait;
4use inquire::InquireError;
5
6use crate::error::WizardError;
7
8/// Outcome of a single [`WizardStep::prompt`] call.
9///
10/// `Next` advances to the next step (or finishes the wizard if it
11/// was the last one). `Back` re-runs the previous step with the
12/// current state — or, on the first step, surfaces
13/// [`WizardError::Cancelled`].
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum StepOutcome {
16 /// Advance to the next step.
17 Next,
18 /// Re-run the previous step. First-step `Back` cancels the wizard.
19 Back,
20}
21
22/// One stage in a [`Wizard`].
23///
24/// Implementations issue one or more `inquire` prompts, mutate
25/// `state` with the captured values, and return a [`StepOutcome`].
26/// `inquire::InquireError::OperationCanceled` (Esc) is mapped to
27/// [`StepOutcome::Back`] by the wizard driver — implementations
28/// just `?`-propagate.
29#[async_trait]
30pub trait WizardStep<S>: Send + Sync {
31 /// Stable identifier for this step. Used in error messages and
32 /// the test harness.
33 fn name(&self) -> &'static str;
34
35 /// Run the step. See trait-level docs for navigation semantics.
36 ///
37 /// # Errors
38 ///
39 /// Surface any [`InquireError`] the step encounters.
40 /// `OperationCanceled` is mapped to `Back` by the driver;
41 /// `OperationInterrupted` short-circuits the wizard with
42 /// `WizardError::Interrupted`; other variants surface as
43 /// `WizardError::Step`.
44 async fn prompt(&self, state: &mut S) -> Result<StepOutcome, InquireError>;
45}
46
47/// Async multi-step interactive form.
48///
49/// Construction is typestate-style via [`Wizard::builder`]:
50///
51/// ```rust,ignore
52/// let final_state = Wizard::builder()
53/// .initial(MyState::default())
54/// .step(Step1)
55/// .step(Step2)
56/// .build()
57/// .run()
58/// .await?;
59/// ```
60///
61/// `Wizard` owns its state and steps. `run` consumes both, returns
62/// the mutated state on success.
63pub struct Wizard<S> {
64 initial: S,
65 steps: Vec<Box<dyn WizardStep<S>>>,
66}
67
68impl<S: Send + 'static> Wizard<S> {
69 /// Start a [`WizardBuilder`].
70 #[must_use]
71 pub fn builder() -> WizardBuilder<S> {
72 WizardBuilder { initial: None, steps: Vec::new() }
73 }
74
75 /// Run the wizard interactively, returning the final state.
76 ///
77 /// # Errors
78 ///
79 /// - [`WizardError::Cancelled`] — user escaped from the first step.
80 /// - [`WizardError::Interrupted`] — Ctrl+C from any step.
81 /// - [`WizardError::Step`] — any other unhandled `InquireError`.
82 pub async fn run(self) -> Result<S, WizardError> {
83 let Self { mut initial, steps } = self;
84 if steps.is_empty() {
85 return Ok(initial);
86 }
87 let mut idx = 0usize;
88 loop {
89 let step = &steps[idx];
90 match step.prompt(&mut initial).await {
91 Ok(StepOutcome::Next) => {
92 idx += 1;
93 if idx >= steps.len() {
94 return Ok(initial);
95 }
96 }
97 // Both explicit Back and an Esc-canceled prompt
98 // navigate the same way: previous step, or cancel
99 // the wizard if we're already on the first step.
100 Ok(StepOutcome::Back) | Err(InquireError::OperationCanceled) => {
101 if idx == 0 {
102 return Err(WizardError::Cancelled);
103 }
104 idx -= 1;
105 }
106 Err(InquireError::OperationInterrupted) => {
107 return Err(WizardError::Interrupted);
108 }
109 Err(other) => {
110 return Err(WizardError::Step {
111 step: step.name().to_string(),
112 message: other.to_string(),
113 });
114 }
115 }
116 }
117 }
118}
119
120/// Builder for [`Wizard`].
121///
122/// Built by hand rather than via `bon` because the field-accumulator
123/// pattern (`.step(...).step(...)`) is the natural ergonomic and
124/// `bon`'s `field`-arg attribute would still require us to write the
125/// `step` method ourselves.
126pub struct WizardBuilder<S> {
127 initial: Option<S>,
128 steps: Vec<Box<dyn WizardStep<S>>>,
129}
130
131impl<S: Send + 'static> WizardBuilder<S> {
132 /// Set the initial state. Required before [`Self::build`].
133 #[must_use]
134 pub fn initial(mut self, state: S) -> Self {
135 self.initial = Some(state);
136 self
137 }
138
139 /// Append a [`WizardStep`] to the wizard. Steps run in
140 /// registration order; back-navigation goes one step earlier.
141 #[must_use]
142 pub fn step<W>(mut self, step: W) -> Self
143 where
144 W: WizardStep<S> + 'static,
145 {
146 self.steps.push(Box::new(step));
147 self
148 }
149
150 /// Finalise the wizard.
151 ///
152 /// # Panics
153 ///
154 /// Panics if [`Self::initial`] was not called. Construction is
155 /// typestate-checked at the type level in v0.2 (`bon`-derived);
156 /// for v0.1 the panic-on-misuse contract is documented and
157 /// enforced via a test.
158 #[must_use]
159 pub fn build(self) -> Wizard<S> {
160 Wizard {
161 initial: self.initial.expect("Wizard::builder requires .initial(...)"),
162 steps: self.steps,
163 }
164 }
165}