Skip to main content

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}