Skip to main content

rskit_cli/prompt/kinds/
mod.rs

1//! The prompt kinds: one behavioural module per question type.
2//!
3//! Each kind implements the same question three ways from one place:
4//!
5//! - **non-interactive** — resolve to the declared default or return a typed
6//!   error, without touching the terminal;
7//! - **line-driven** — print a numbered list (or plain prompt) once and parse a
8//!   typed answer, re-asking on invalid input;
9//! - **key-driven** — draw a live frame and update it in place as arrow keys
10//!   move focus and space toggles selection.
11//!
12//! A kind chooses the interactive path by inspecting
13//! [`Terminal::capabilities`], so the
14//! prompter never branches on a concrete terminal type.
15
16pub mod confirm;
17pub mod multi_select;
18pub mod select;
19pub mod text;
20
21use rskit_errors::AppError;
22
23use super::render::Style;
24use super::terminal::Terminal;
25
26/// The error returned when a non-interactive prompt has no usable default.
27fn non_interactive_error(prompt: &str) -> AppError {
28    AppError::invalid_input(
29        "prompt",
30        format!("non-interactive mode requires a default for: {prompt}"),
31    )
32}
33
34/// The error returned when input closes before the prompt is answered.
35fn closed_input(prompt: &str) -> AppError {
36    AppError::invalid_input("prompt", format!("input closed before answering: {prompt}"))
37}
38
39/// The error returned when the user cancels an interactive prompt (Esc/Ctrl+C).
40fn cancelled(prompt: &str) -> AppError {
41    AppError::cancelled(format!("prompt cancelled: {prompt}"))
42}
43
44/// Run `body` between [`Terminal::begin_interactive`] and
45/// [`Terminal::end_interactive`], restoring cooked mode on every exit path —
46/// normal return, error, or an unwinding panic — via an RAII guard. On the
47/// normal path teardown runs explicitly so `body`'s error is preferred over a
48/// teardown error; if `body` panics the guard runs teardown during unwind so
49/// the terminal is never left in raw mode.
50fn with_raw_mode<T, R>(
51    terminal: &mut T,
52    body: impl FnOnce(&mut T) -> rskit_errors::AppResult<R>,
53) -> rskit_errors::AppResult<R>
54where
55    T: Terminal + ?Sized,
56{
57    terminal.begin_interactive()?;
58    let mut guard = RawModeGuard {
59        terminal,
60        armed: true,
61    };
62    let result = body(&mut *guard.terminal);
63    // Disarm and capture teardown so its error can be surfaced on the Ok path;
64    // if `body` panicked instead, the guard's Drop restores cooked mode.
65    let teardown = guard.disarm();
66    match result {
67        Ok(value) => teardown.map(|()| value),
68        Err(error) => Err(error),
69    }
70}
71
72/// RAII guard that restores cooked mode on drop unless explicitly disarmed.
73struct RawModeGuard<'a, T: Terminal + ?Sized> {
74    terminal: &'a mut T,
75    armed: bool,
76}
77
78impl<T: Terminal + ?Sized> RawModeGuard<'_, T> {
79    /// Run teardown on the normal path, returning its result so callers can
80    /// surface it. Only disarms the `Drop` net when teardown *succeeds*, so a
81    /// failed teardown leaves the guard armed and `Drop` retries as a
82    /// best-effort net rather than leaving the terminal stuck in raw mode.
83    fn disarm(&mut self) -> rskit_errors::AppResult<()> {
84        let result = self.terminal.end_interactive();
85        if result.is_ok() {
86            self.armed = false;
87        }
88        result
89    }
90}
91
92impl<T: Terminal + ?Sized> Drop for RawModeGuard<'_, T> {
93    fn drop(&mut self) {
94        if self.armed {
95            let _ = self.terminal.end_interactive();
96        }
97    }
98}
99
100/// Step focus up by one, wrapping to the last row.
101const fn focus_up(cursor: usize, len: usize) -> usize {
102    if cursor == 0 { len - 1 } else { cursor - 1 }
103}
104
105/// Step focus down by one, wrapping to the first row.
106const fn focus_down(cursor: usize, len: usize) -> usize {
107    if cursor + 1 >= len { 0 } else { cursor + 1 }
108}
109
110/// Write the inline answer marker (`» [hint]: `, ASCII-safe via
111/// [`Glyphs`](crate::theme::Glyphs)) and flush, for line prompts.
112fn write_answer(
113    terminal: &mut (impl Terminal + ?Sized),
114    style: Style,
115    hint: Option<&str>,
116) -> rskit_errors::AppResult<()> {
117    let marker = style.glyphs().answer();
118    let text = hint.map_or_else(
119        || format!("  {marker} "),
120        |hint| format!("  {marker} {hint}: "),
121    );
122    terminal.write(&text)?;
123    terminal.flush()
124}
125
126/// Write a dimmed warning notice line beneath a line prompt.
127fn notice(
128    terminal: &mut (impl Terminal + ?Sized),
129    style: Style,
130    text: &str,
131) -> rskit_errors::AppResult<()> {
132    let styled = style.palette().warn(text).into_owned();
133    terminal.write_line(&format!("  {styled}"))
134}
135
136/// Parse a one-based choice number into a zero-based index within `0..len`.
137fn parse_index(input: &str, len: usize) -> Option<usize> {
138    let number: usize = input.parse().ok()?;
139    (1..=len).contains(&number).then(|| number - 1)
140}
141
142#[cfg(test)]
143mod tests {
144    use std::cell::Cell;
145
146    use rskit_errors::{AppError, AppResult};
147
148    use super::{focus_down, focus_up, with_raw_mode};
149    use crate::prompt::terminal::{Capabilities, ScriptedTerminal, Terminal};
150
151    /// A terminal whose `end_interactive` fails a fixed number of times before
152    /// succeeding, and that counts every teardown attempt, so tests can prove
153    /// the `Drop` net retries after an explicit teardown fails.
154    struct FlakyTeardown {
155        fail_remaining: Cell<u32>,
156        teardown_calls: Cell<u32>,
157        raw: Cell<bool>,
158    }
159
160    impl FlakyTeardown {
161        fn failing(times: u32) -> Self {
162            Self {
163                fail_remaining: Cell::new(times),
164                teardown_calls: Cell::new(0),
165                raw: Cell::new(false),
166            }
167        }
168    }
169
170    impl Terminal for FlakyTeardown {
171        fn capabilities(&self) -> Capabilities {
172            Capabilities::key_driven()
173        }
174        fn read_line(&mut self) -> AppResult<Option<String>> {
175            Ok(None)
176        }
177        fn read_key(&mut self) -> AppResult<crate::prompt::Key> {
178            Err(AppError::invalid_input("terminal", "no keys scripted"))
179        }
180        fn write(&mut self, _text: &str) -> AppResult<()> {
181            Ok(())
182        }
183        fn write_line(&mut self, _text: &str) -> AppResult<()> {
184            Ok(())
185        }
186        fn flush(&mut self) -> AppResult<()> {
187            Ok(())
188        }
189        fn clear_last_lines(&mut self, _count: u16) -> AppResult<()> {
190            Ok(())
191        }
192        fn begin_interactive(&mut self) -> AppResult<()> {
193            self.raw.set(true);
194            Ok(())
195        }
196        fn end_interactive(&mut self) -> AppResult<()> {
197            self.teardown_calls.set(self.teardown_calls.get() + 1);
198            let remaining = self.fail_remaining.get();
199            if remaining > 0 {
200                self.fail_remaining.set(remaining - 1);
201                return Err(AppError::invalid_input("terminal", "teardown failed"));
202            }
203            self.raw.set(false);
204            Ok(())
205        }
206    }
207
208    #[test]
209    fn focus_wraps_both_ends() {
210        assert_eq!(focus_up(0, 3), 2);
211        assert_eq!(focus_up(2, 3), 1);
212        assert_eq!(focus_down(2, 3), 0);
213        assert_eq!(focus_down(0, 3), 1);
214    }
215
216    #[test]
217    fn raw_mode_restored_on_normal_return() {
218        let mut term = ScriptedTerminal::key_driven();
219        let out = with_raw_mode(&mut term, |t| {
220            assert!(t.is_interactive());
221            Ok(7)
222        });
223        assert_eq!(out.expect("ok"), 7);
224        assert!(!term.is_interactive());
225    }
226
227    #[test]
228    fn raw_mode_restored_when_body_panics() {
229        // A caller may catch the unwind and keep the terminal alive; the RAII
230        // guard must still have run end_interactive() during unwinding so the
231        // terminal is not stranded in raw mode.
232        let mut term = ScriptedTerminal::key_driven();
233        let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
234            with_raw_mode(&mut term, |_| -> rskit_errors::AppResult<()> {
235                panic!("body blew up mid-prompt");
236            })
237        }));
238        assert!(unwound.is_err());
239        assert!(!term.is_interactive());
240    }
241
242    #[test]
243    fn failed_teardown_is_retried_by_drop_net() {
244        // The explicit teardown on the normal path fails once; the guard must
245        // stay armed so Drop retries, ultimately restoring cooked mode.
246        let mut term = FlakyTeardown::failing(1);
247        let out = with_raw_mode(&mut term, |t| {
248            // Exercise the full terminal surface so the double is covered end to
249            // end: capabilities, both reads, both writes, flush, and clear.
250            assert!(t.capabilities().is_key_driven());
251            t.write("x")?;
252            t.write_line("y")?;
253            t.flush()?;
254            t.clear_last_lines(1)?;
255            assert_eq!(t.read_line()?, None);
256            assert!(t.read_key().is_err());
257            Ok(())
258        });
259        // The failed explicit teardown surfaces on the Ok path.
260        assert!(out.is_err());
261        // Called twice: the failing explicit attempt plus the successful retry.
262        assert_eq!(term.teardown_calls.get(), 2);
263        assert!(!term.raw.get());
264    }
265}