Skip to main content

rskit_cli/prompt/
prompter.rs

1//! The [`Prompter`]: a terminal-agnostic driver for every prompt kind.
2//!
3//! A `Prompter` binds three things — a [`Terminal`], a resolved [`PromptMode`],
4//! and a rendering [`Style`] — and exposes one method per question type.
5//! The prompt-kind logic lives in [`super::kinds`];
6//! the prompter only wires the shared state through to it, so the same call works over cooked stdio,
7//! a raw-mode TTY, or a scripted test double.
8//!
9//! Build one from the environment with [`Prompter::from_env`] —
10//! which auto-selects a rich raw-mode terminal when one is available
11//! and the `interactive` feature is compiled, else a line terminal —
12//! or from explicit parts with [`Prompter::new`] for deterministic tests.
13
14use std::io::IsTerminal;
15
16use rskit_errors::AppResult;
17
18use super::choice::{Choice, ChoiceId};
19use super::kinds;
20use super::mode::PromptMode;
21use super::render::Style;
22use super::terminal::{LineTerminal, Terminal};
23use super::validate::Validator;
24use crate::theme::{ColorChoice, Glyphs, Palette};
25
26/// A terminal-agnostic prompt driver.
27///
28/// Generic over its [`Terminal`]
29/// so tests can bind a [`ScriptedTerminal`](super::terminal::ScriptedTerminal) directly while [`Prompter::from_env`] erases the concrete terminal behind a `Box<dyn Terminal>`.
30pub struct Prompter<T> {
31    terminal: T,
32    mode: PromptMode,
33    style: Style,
34}
35
36impl Prompter<Box<dyn Terminal>> {
37    /// Build a prompter bound to the process environment.
38    ///
39    /// The [`PromptMode`] follows whether both stdin and stderr are terminals,
40    /// and the [`Palette`] follows `color` against stderr, so interactivity
41    /// and styling both honour redirection and `NO_COLOR`. Prompts render to stderr,
42    /// so a redirected stderr (e.g. `cmd 2>log`) forces [`PromptMode::NonInteractive`] rather than blocking on input behind an invisible prompt.
43    /// When the `interactive` feature is compiled and both streams are terminals,
44    /// a rich raw-mode terminal is selected for arrow-key navigation; otherwise a line terminal is used.
45    #[must_use]
46    pub fn from_env(color: ColorChoice) -> Self {
47        let stderr = std::io::stderr();
48        let mode = PromptMode::from_stdio(std::io::stdin().is_terminal(), stderr.is_terminal());
49        let palette = Palette::for_stream(color, &stderr);
50        let glyphs = Glyphs::from_env();
51        let terminal = resolve_terminal(mode.is_interactive());
52        Self {
53            terminal,
54            mode,
55            style: Style::new(palette, glyphs),
56        }
57    }
58}
59
60impl<T: Terminal> Prompter<T> {
61    /// Build a prompter from an explicit terminal, mode, and palette.
62    ///
63    /// Glyphs default to the ASCII fallback for byte-clean, deterministic tests;
64    /// override with [`Prompter::with_glyphs`].
65    #[must_use]
66    pub const fn new(terminal: T, mode: PromptMode, palette: Palette) -> Self {
67        Self {
68            terminal,
69            mode,
70            style: Style::new(palette, Glyphs::new(false)),
71        }
72    }
73
74    /// Override the glyph set (Unicode symbols vs ASCII fallback).
75    #[must_use]
76    pub const fn with_glyphs(mut self, glyphs: Glyphs) -> Self {
77        self.style = Style::new(self.style.palette(), glyphs);
78        self
79    }
80
81    /// The resolved interaction mode.
82    #[must_use]
83    pub const fn mode(&self) -> PromptMode {
84        self.mode
85    }
86
87    /// The bound terminal, for inspecting captured output in tests.
88    #[must_use]
89    pub const fn terminal(&self) -> &T {
90        &self.terminal
91    }
92
93    /// Build the invariant presentation context for a prompt from the bound
94    /// style and mode.
95    const fn ask<'a>(&self, prompt: &'a str) -> kinds::Ask<'a> {
96        kinds::Ask {
97            style: self.style,
98            mode: self.mode,
99            prompt,
100        }
101    }
102
103    /// Ask for exactly one choice.
104    ///
105    /// In [`PromptMode::NonInteractive`] this resolves to the recommended choice;
106    /// with none it is a typed error. A key-driven terminal shows an arrow-key radio list;
107    /// a line-driven terminal shows a numbered list.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error when `choices` is empty, when a non-interactive prompt has no recommended default,
112    /// when the user cancels, or when input closes early.
113    pub fn select(&mut self, prompt: &str, choices: &[Choice]) -> AppResult<ChoiceId> {
114        let ask = self.ask(prompt);
115        kinds::select::run(&mut self.terminal, ask, choices)
116    }
117
118    /// Ask for zero or more choices.
119    ///
120    /// The default answer is the set of recommended choices, which may be empty.
121    /// A key-driven terminal shows an arrow-key checkbox list;
122    /// a line-driven terminal accepts a comma-separated list of numbers.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error when `choices` is empty, when the user cancels, or when input closes early.
127    pub fn multi_select(&mut self, prompt: &str, choices: &[Choice]) -> AppResult<Vec<ChoiceId>> {
128        let ask = self.ask(prompt);
129        kinds::multi_select::run(&mut self.terminal, ask, choices)
130    }
131
132    /// Ask a yes/no question with an explicit default.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error when the user cancels or when input closes early.
137    pub fn confirm(&mut self, prompt: &str, default: bool) -> AppResult<bool> {
138        let ask = self.ask(prompt);
139        kinds::confirm::run(&mut self.terminal, ask, default)
140    }
141
142    /// Ask for freeform text with an optional default.
143    ///
144    /// In [`PromptMode::NonInteractive`] this resolves to `default`; with none it is a typed error.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error when a non-interactive prompt has no default, when the user cancels,
149    /// or when input closes early.
150    pub fn text(&mut self, prompt: &str, default: Option<&str>) -> AppResult<String> {
151        let ask = self.ask(prompt);
152        kinds::text::run(&mut self.terminal, ask, default, None)
153    }
154
155    /// Ask for freeform text validated by `validator`, re-asking on rejection.
156    ///
157    /// In [`PromptMode::NonInteractive`] a rejected default is a typed error rather than a silent bad value.
158    ///
159    /// # Errors
160    ///
161    /// Returns an error when a non-interactive prompt has no default or a rejected default,
162    /// when the user cancels, or when input closes early.
163    pub fn text_with(
164        &mut self,
165        prompt: &str,
166        default: Option<&str>,
167        validator: &dyn Validator,
168    ) -> AppResult<String> {
169        let ask = self.ask(prompt);
170        kinds::text::run(&mut self.terminal, ask, default, Some(validator))
171    }
172}
173
174/// Select the concrete terminal for [`Prompter::from_env`]: rich when a TTY is present
175/// and the `interactive` feature is compiled, else line.
176fn resolve_terminal(is_tty: bool) -> Box<dyn Terminal> {
177    #[cfg(feature = "interactive")]
178    {
179        if is_tty && let Ok(terminal) = super::terminal::RichTerminal::stderr() {
180            return Box::new(terminal);
181        }
182    }
183    #[cfg(not(feature = "interactive"))]
184    let _ = is_tty;
185    Box::new(LineTerminal::stdio())
186}
187
188#[cfg(test)]
189mod tests {
190    use super::{Choice, ChoiceId, Palette, PromptMode, Prompter};
191    use crate::prompt::key::Key;
192    use crate::prompt::terminal::ScriptedTerminal;
193    use crate::prompt::validate::non_empty;
194    use crate::theme::{ColorChoice, Glyphs};
195
196    fn plain_choices() -> Vec<Choice> {
197        vec![
198            Choice::new("go", "Go"),
199            Choice::new("rust", "Rust").recommended(),
200            Choice::new("node", "Node.js"),
201        ]
202    }
203
204    fn no_default_choices() -> Vec<Choice> {
205        vec![Choice::new("go", "Go"), Choice::new("rust", "Rust")]
206    }
207
208    fn line_prompter(
209        terminal: ScriptedTerminal,
210        mode: PromptMode,
211        color: bool,
212    ) -> Prompter<ScriptedTerminal> {
213        Prompter::new(terminal, mode, Palette::new(color))
214    }
215
216    // ── Non-interactive resolution ──────────────────────────────────────
217
218    #[test]
219    fn non_interactive_select_resolves_to_recommended() {
220        let choice = line_prompter(
221            ScriptedTerminal::line_driven(),
222            PromptMode::NonInteractive,
223            false,
224        )
225        .select("Ecosystem?", &plain_choices())
226        .expect("recommended default resolves");
227        assert_eq!(choice, ChoiceId::new("rust"));
228    }
229
230    #[test]
231    fn non_interactive_select_without_default_errors() {
232        let choices = vec![Choice::new("go", "Go"), Choice::new("rust", "Rust")];
233        let err = line_prompter(
234            ScriptedTerminal::line_driven(),
235            PromptMode::NonInteractive,
236            false,
237        )
238        .select("Ecosystem?", &choices)
239        .expect_err("no recommended default must error");
240        assert!(err.message().contains("non-interactive"));
241    }
242
243    #[test]
244    fn non_interactive_multi_select_returns_recommended_set() {
245        let selected = line_prompter(
246            ScriptedTerminal::line_driven(),
247            PromptMode::NonInteractive,
248            false,
249        )
250        .multi_select("Tasks?", &plain_choices())
251        .expect("recommended set resolves");
252        assert_eq!(selected, vec![ChoiceId::new("rust")]);
253    }
254
255    #[test]
256    fn non_interactive_multi_select_allows_empty_default() {
257        let choices = vec![Choice::new("a", "A"), Choice::new("b", "B")];
258        let selected = line_prompter(
259            ScriptedTerminal::line_driven(),
260            PromptMode::NonInteractive,
261            false,
262        )
263        .multi_select("Tasks?", &choices)
264        .expect("empty selection is valid");
265        assert!(selected.is_empty());
266    }
267
268    #[test]
269    fn non_interactive_confirm_returns_default() {
270        let value = line_prompter(
271            ScriptedTerminal::line_driven(),
272            PromptMode::NonInteractive,
273            false,
274        )
275        .confirm("Proceed?", true)
276        .expect("confirm resolves to default");
277        assert!(value);
278    }
279
280    #[test]
281    fn non_interactive_text_uses_default_and_errors_without_one() {
282        let value = line_prompter(
283            ScriptedTerminal::line_driven(),
284            PromptMode::NonInteractive,
285            false,
286        )
287        .text("Name?", Some("toven"))
288        .expect("text resolves to default");
289        assert_eq!(value, "toven");
290
291        let err = line_prompter(
292            ScriptedTerminal::line_driven(),
293            PromptMode::NonInteractive,
294            false,
295        )
296        .text("Name?", None)
297        .expect_err("no default must error");
298        assert!(err.message().contains("non-interactive"));
299    }
300
301    #[test]
302    fn non_interactive_text_with_rejects_invalid_default() {
303        let err = line_prompter(
304            ScriptedTerminal::line_driven(),
305            PromptMode::NonInteractive,
306            false,
307        )
308        .text_with("Name?", Some("  "), &non_empty("required"))
309        .expect_err("invalid default must error");
310        assert!(err.message().contains("invalid"));
311    }
312
313    // ── Line-driven interaction ─────────────────────────────────────────
314
315    #[test]
316    fn line_select_reads_a_numbered_answer() {
317        let choice = line_prompter(
318            ScriptedTerminal::line_driven().with_line("1"),
319            PromptMode::Interactive,
320            false,
321        )
322        .select("Ecosystem?", &plain_choices())
323        .expect("first choice selected");
324        assert_eq!(choice, ChoiceId::new("go"));
325    }
326
327    #[test]
328    fn line_select_empty_line_accepts_recommended() {
329        let choice = line_prompter(
330            ScriptedTerminal::line_driven().with_line(""),
331            PromptMode::Interactive,
332            false,
333        )
334        .select("Ecosystem?", &plain_choices())
335        .expect("blank accepts recommended");
336        assert_eq!(choice, ChoiceId::new("rust"));
337    }
338
339    #[test]
340    fn line_select_reprompts_on_invalid_then_succeeds() {
341        let mut prompter = line_prompter(
342            ScriptedTerminal::line_driven().with_lines(["9", "x", "3"]),
343            PromptMode::Interactive,
344            false,
345        );
346        let choice = prompter
347            .select("Ecosystem?", &plain_choices())
348            .expect("valid choice after retries");
349        assert_eq!(choice, ChoiceId::new("node"));
350        assert!(prompter.terminal().output().contains("between 1 and 3"));
351    }
352
353    #[test]
354    fn line_select_errors_when_input_closes() {
355        let choices = vec![Choice::new("go", "Go"), Choice::new("rust", "Rust")];
356        let err = line_prompter(
357            ScriptedTerminal::line_driven(),
358            PromptMode::Interactive,
359            false,
360        )
361        .select("Ecosystem?", &choices)
362        .expect_err("closed input must error, not hang");
363        assert!(err.message().contains("input closed"));
364    }
365
366    #[test]
367    fn line_multi_select_parses_and_dedupes() {
368        let selected = line_prompter(
369            ScriptedTerminal::line_driven().with_line("3, 1, 1"),
370            PromptMode::Interactive,
371            false,
372        )
373        .multi_select("Tasks?", &plain_choices())
374        .expect("comma list parses");
375        assert_eq!(selected, vec![ChoiceId::new("node"), ChoiceId::new("go")]);
376    }
377
378    #[test]
379    fn line_multi_select_empty_line_uses_defaults() {
380        let selected = line_prompter(
381            ScriptedTerminal::line_driven().with_line(""),
382            PromptMode::Interactive,
383            false,
384        )
385        .multi_select("Tasks?", &plain_choices())
386        .expect("blank uses recommended defaults");
387        assert_eq!(selected, vec![ChoiceId::new("rust")]);
388    }
389
390    #[test]
391    fn line_confirm_parses_yes_no_and_default() {
392        assert!(
393            line_prompter(
394                ScriptedTerminal::line_driven().with_line("y"),
395                PromptMode::Interactive,
396                false
397            )
398            .confirm("Proceed?", false)
399            .expect("yes")
400        );
401        assert!(
402            !line_prompter(
403                ScriptedTerminal::line_driven().with_line("no"),
404                PromptMode::Interactive,
405                false
406            )
407            .confirm("Proceed?", true)
408            .expect("no")
409        );
410        assert!(
411            line_prompter(
412                ScriptedTerminal::line_driven().with_line(""),
413                PromptMode::Interactive,
414                false
415            )
416            .confirm("Proceed?", true)
417            .expect("blank uses default")
418        );
419    }
420
421    #[test]
422    fn line_text_reads_value_and_falls_back_to_default() {
423        let value = line_prompter(
424            ScriptedTerminal::line_driven().with_line("custom"),
425            PromptMode::Interactive,
426            false,
427        )
428        .text("Name?", Some("toven"))
429        .expect("typed value");
430        assert_eq!(value, "custom");
431
432        let value = line_prompter(
433            ScriptedTerminal::line_driven().with_line(""),
434            PromptMode::Interactive,
435            false,
436        )
437        .text("Name?", Some("toven"))
438        .expect("blank uses default");
439        assert_eq!(value, "toven");
440    }
441
442    #[test]
443    fn line_text_with_reasks_until_valid() {
444        let mut prompter = line_prompter(
445            ScriptedTerminal::line_driven().with_lines(["  ", "ok"]),
446            PromptMode::Interactive,
447            false,
448        );
449        let value = prompter
450            .text_with("Name?", None, &non_empty("required"))
451            .expect("valid after retry");
452        assert_eq!(value, "ok");
453        assert!(prompter.terminal().output().contains("required"));
454    }
455
456    // ── Key-driven interaction ──────────────────────────────────────────
457
458    #[test]
459    fn key_select_navigates_and_confirms() {
460        let choice = line_prompter(
461            ScriptedTerminal::key_driven().with_keys([Key::Down, Key::Enter]),
462            PromptMode::Interactive,
463            false,
464        )
465        .select("Ecosystem?", &plain_choices())
466        .expect("arrow navigation selects");
467        assert_eq!(choice, ChoiceId::new("node"));
468    }
469
470    #[test]
471    fn key_select_starts_on_recommended_default() {
472        let choice = line_prompter(
473            ScriptedTerminal::key_driven().with_key(Key::Enter),
474            PromptMode::Interactive,
475            false,
476        )
477        .select("Ecosystem?", &plain_choices())
478        .expect("enter accepts default");
479        assert_eq!(choice, ChoiceId::new("rust"));
480    }
481
482    #[test]
483    fn key_select_escape_cancels() {
484        let err = line_prompter(
485            ScriptedTerminal::key_driven().with_key(Key::Escape),
486            PromptMode::Interactive,
487            false,
488        )
489        .select("Ecosystem?", &plain_choices())
490        .expect_err("escape cancels");
491        assert!(err.message().contains("cancelled"));
492    }
493
494    #[test]
495    fn key_multi_select_toggles_selection() {
496        // Recommended (Rust, index 1) starts selected; toggle Go on, Rust off, Node on,
497        // to exercise toggling in both directions.
498        let selected = line_prompter(
499            ScriptedTerminal::key_driven().with_keys([
500                Key::Space, // Go on
501                Key::Down,
502                Key::Space, // Rust off
503                Key::Down,
504                Key::Space, // Node on
505                Key::Enter,
506            ]),
507            PromptMode::Interactive,
508            false,
509        )
510        .multi_select("Tasks?", &plain_choices())
511        .expect("space toggles");
512        assert_eq!(selected, vec![ChoiceId::new("go"), ChoiceId::new("node")]);
513    }
514
515    #[test]
516    fn key_confirm_reads_letter() {
517        assert!(
518            line_prompter(
519                ScriptedTerminal::key_driven().with_key(Key::Char('y')),
520                PromptMode::Interactive,
521                false
522            )
523            .confirm("Proceed?", false)
524            .expect("y confirms")
525        );
526    }
527
528    #[test]
529    fn key_text_edits_with_backspace() {
530        let value = line_prompter(
531            ScriptedTerminal::key_driven().with_keys([
532                Key::Char('h'),
533                Key::Char('i'),
534                Key::Char('x'),
535                Key::Backspace,
536                Key::Enter,
537            ]),
538            PromptMode::Interactive,
539            false,
540        )
541        .text("Name?", None)
542        .expect("typed value");
543        assert_eq!(value, "hi");
544    }
545
546    #[test]
547    fn key_text_inserts_literal_space() {
548        // The space bar decodes to Key::Space (not Key::Char(' ')),
549        // so text entry must treat it as a literal space —
550        // otherwise multi-word answers are impossible on a real rich terminal.
551        let value = line_prompter(
552            ScriptedTerminal::key_driven().with_keys([
553                Key::Char('h'),
554                Key::Char('i'),
555                Key::Space,
556                Key::Char('t'),
557                Key::Char('h'),
558                Key::Char('e'),
559                Key::Char('r'),
560                Key::Char('e'),
561                Key::Enter,
562            ]),
563            PromptMode::Interactive,
564            false,
565        )
566        .text("Name?", None)
567        .expect("typed value");
568        assert_eq!(value, "hi there");
569    }
570
571    #[test]
572    fn key_select_runs_in_raw_mode_and_restores() {
573        let mut prompter = line_prompter(
574            ScriptedTerminal::key_driven().with_key(Key::Enter),
575            PromptMode::Interactive,
576            false,
577        );
578        let _ = prompter
579            .select("Ecosystem?", &plain_choices())
580            .expect("select");
581        assert!(!prompter.terminal().is_interactive(), "raw mode restored");
582    }
583
584    // ── Rendering & metadata ────────────────────────────────────────────
585
586    #[test]
587    fn choice_metadata_round_trips_into_rendered_output() {
588        let choices = vec![
589            Choice::new("rust", "Rust")
590                .with_annotation("detected in dev-deps")
591                .recommended(),
592            Choice::new("go", "Go"),
593        ];
594        let mut prompter = line_prompter(
595            ScriptedTerminal::line_driven().with_line("1"),
596            PromptMode::Interactive,
597            false,
598        );
599        let _ = prompter.select("Ecosystem?", &choices).expect("select");
600        let rendered = prompter.terminal().output();
601        assert!(rendered.contains("Rust"));
602        assert!(rendered.contains("detected in dev-deps"));
603        assert!(rendered.contains("(recommended)"));
604    }
605
606    #[test]
607    fn disabled_palette_renders_without_ansi_escapes() {
608        let mut prompter = line_prompter(
609            ScriptedTerminal::line_driven().with_line("1"),
610            PromptMode::Interactive,
611            false,
612        );
613        let _ = prompter
614            .select("Ecosystem?", &plain_choices())
615            .expect("select");
616        assert!(
617            !prompter.terminal().output().contains('\u{1b}'),
618            "no color must be byte-clean"
619        );
620    }
621
622    #[test]
623    fn enabled_palette_emits_ansi_escapes() {
624        let mut prompter = line_prompter(
625            ScriptedTerminal::line_driven().with_line("1"),
626            PromptMode::Interactive,
627            true,
628        );
629        let _ = prompter
630            .select("Ecosystem?", &plain_choices())
631            .expect("select");
632        assert!(
633            prompter.terminal().output().contains('\u{1b}'),
634            "color must emit SGR escapes"
635        );
636    }
637
638    #[test]
639    fn empty_choice_set_is_rejected() {
640        let err = line_prompter(
641            ScriptedTerminal::line_driven(),
642            PromptMode::Interactive,
643            false,
644        )
645        .select("Ecosystem?", &[])
646        .expect_err("empty choices rejected");
647        assert!(err.message().contains("at least one choice"));
648    }
649
650    #[test]
651    fn multi_select_empty_choice_set_is_rejected() {
652        let err = line_prompter(
653            ScriptedTerminal::line_driven(),
654            PromptMode::Interactive,
655            false,
656        )
657        .multi_select("Tasks?", &[])
658        .expect_err("empty choices rejected");
659        assert!(err.message().contains("at least one choice"));
660    }
661
662    // ── Confirm: key- and line-driven branches ──────────────────────────
663
664    #[test]
665    fn key_confirm_enter_default_letter_no_and_escape() {
666        // Enter accepts the default.
667        assert!(
668            line_prompter(
669                ScriptedTerminal::key_driven().with_key(Key::Enter),
670                PromptMode::Interactive,
671                false
672            )
673            .confirm("Proceed?", true)
674            .expect("enter accepts default")
675        );
676        // 'n' declines.
677        assert!(
678            !line_prompter(
679                ScriptedTerminal::key_driven().with_key(Key::Char('n')),
680                PromptMode::Interactive,
681                false
682            )
683            .confirm("Proceed?", true)
684            .expect("n declines")
685        );
686        // An unrelated key is ignored before 'Y' confirms.
687        assert!(
688            line_prompter(
689                ScriptedTerminal::key_driven().with_keys([Key::Left, Key::Char('Y')]),
690                PromptMode::Interactive,
691                false
692            )
693            .confirm("Proceed?", false)
694            .expect("Y confirms after an ignored key")
695        );
696        // Escape cancels.
697        let err = line_prompter(
698            ScriptedTerminal::key_driven().with_key(Key::Escape),
699            PromptMode::Interactive,
700            false,
701        )
702        .confirm("Proceed?", true)
703        .expect_err("escape cancels");
704        assert!(err.message().contains("cancelled"));
705    }
706
707    #[test]
708    fn line_confirm_reprompts_on_invalid_and_errors_on_close() {
709        let mut prompter = line_prompter(
710            ScriptedTerminal::line_driven().with_lines(["maybe", "yes"]),
711            PromptMode::Interactive,
712            false,
713        );
714        assert!(
715            prompter
716                .confirm("Proceed?", false)
717                .expect("yes after retry")
718        );
719        assert!(prompter.terminal().output().contains("'y' or 'n'"));
720
721        let err = line_prompter(
722            ScriptedTerminal::line_driven(),
723            PromptMode::Interactive,
724            false,
725        )
726        .confirm("Proceed?", true)
727        .expect_err("closed input must error");
728        assert!(err.message().contains("input closed"));
729    }
730
731    // ── Select: extra key navigation and line branches ──────────────────
732
733    #[test]
734    fn key_select_home_end_and_up_navigate_and_ignore_unrelated_keys() {
735        // Start on the recommended default (Rust, index 1): End→node(2), Home→go(0), Down→rust(1),
736        // Up→go(0), Tab→rust(1), Left is ignored, Enter confirms Rust.
737        let choice = line_prompter(
738            ScriptedTerminal::key_driven().with_keys([
739                Key::End,
740                Key::Home,
741                Key::Down,
742                Key::Up,
743                Key::Tab,
744                Key::Left,
745                Key::Enter,
746            ]),
747            PromptMode::Interactive,
748            false,
749        )
750        .select("Ecosystem?", &plain_choices())
751        .expect("navigation resolves");
752        assert_eq!(choice, ChoiceId::new("rust"));
753    }
754
755    #[test]
756    fn line_select_empty_without_default_requires_a_choice() {
757        let mut prompter = line_prompter(
758            ScriptedTerminal::line_driven().with_lines(["", "2"]),
759            PromptMode::Interactive,
760            false,
761        );
762        let choice = prompter
763            .select("Ecosystem?", &no_default_choices())
764            .expect("valid choice after the required notice");
765        assert_eq!(choice, ChoiceId::new("rust"));
766        assert!(
767            prompter
768                .terminal()
769                .output()
770                .contains("a choice is required")
771        );
772    }
773
774    // ── Multi-select: extra key navigation and line branches ────────────
775
776    #[test]
777    fn key_multi_select_escape_cancels_and_navigation_wraps() {
778        let err = line_prompter(
779            ScriptedTerminal::key_driven().with_key(Key::Escape),
780            PromptMode::Interactive,
781            false,
782        )
783        .multi_select("Tasks?", &plain_choices())
784        .expect_err("escape cancels");
785        assert!(err.message().contains("cancelled"));
786
787        // End→2, Home→0, Up→2, Tab→0, Left ignored, Space toggles Go on, Enter.
788        let selected = line_prompter(
789            ScriptedTerminal::key_driven().with_keys([
790                Key::End,
791                Key::Home,
792                Key::Up,
793                Key::Tab,
794                Key::Left,
795                Key::Space,
796                Key::Enter,
797            ]),
798            PromptMode::Interactive,
799            false,
800        )
801        .multi_select("Tasks?", &plain_choices())
802        .expect("navigation and toggle resolve");
803        assert_eq!(selected, vec![ChoiceId::new("go"), ChoiceId::new("rust")]);
804    }
805
806    #[test]
807    fn line_multi_select_notice_none_hint_and_close() {
808        // No recommended choice → the default hint reads `[none]`;
809        // an invalid answer shows a notice before a valid comma list is accepted.
810        let mut prompter = line_prompter(
811            ScriptedTerminal::line_driven().with_lines(["x", "1,2"]),
812            PromptMode::Interactive,
813            false,
814        );
815        let selected = prompter
816            .multi_select("Tasks?", &no_default_choices())
817            .expect("valid list after the notice");
818        assert_eq!(selected, vec![ChoiceId::new("go"), ChoiceId::new("rust")]);
819        let out = prompter.terminal().output();
820        assert!(out.contains("[none]"));
821        assert!(out.contains("comma-separated"));
822
823        let err = line_prompter(
824            ScriptedTerminal::line_driven(),
825            PromptMode::Interactive,
826            false,
827        )
828        .multi_select("Tasks?", &no_default_choices())
829        .expect_err("closed input must error");
830        assert!(err.message().contains("input closed"));
831    }
832
833    // ── Text: required, validator-reason, and cancellation branches ─────
834
835    #[test]
836    fn key_text_required_then_validator_reason_then_value() {
837        let reject_short = |value: &str| {
838            if value.len() >= 2 {
839                Ok(())
840            } else {
841                Err("too short".to_string())
842            }
843        };
844
845        // Enter on an empty buffer with no default surfaces the required notice,
846        // then a typed value is accepted.
847        let value = line_prompter(
848            ScriptedTerminal::key_driven().with_keys([
849                Key::Enter,
850                Key::Char('h'),
851                Key::Char('i'),
852                Key::Enter,
853            ]),
854            PromptMode::Interactive,
855            false,
856        )
857        .text("Name?", None)
858        .expect("value after the required notice");
859        assert_eq!(value, "hi");
860
861        // A rejected value shows the validator reason, then a valid value passes.
862        let mut prompter = line_prompter(
863            ScriptedTerminal::key_driven().with_keys([
864                Key::Char('a'),
865                Key::Enter,
866                Key::Char('b'),
867                Key::Enter,
868            ]),
869            PromptMode::Interactive,
870            false,
871        );
872        let value = prompter
873            .text_with("Name?", None, &reject_short)
874            .expect("valid after the reason");
875        assert_eq!(value, "ab");
876        assert!(prompter.terminal().output().contains("too short"));
877    }
878
879    #[test]
880    fn key_text_ignores_unrelated_keys_and_escape_cancels() {
881        let err = line_prompter(
882            ScriptedTerminal::key_driven().with_keys([Key::Left, Key::Escape]),
883            PromptMode::Interactive,
884            false,
885        )
886        .text("Name?", None)
887        .expect_err("escape cancels");
888        assert!(err.message().contains("cancelled"));
889    }
890
891    #[test]
892    fn line_text_errors_when_input_closes() {
893        let err = line_prompter(
894            ScriptedTerminal::line_driven(),
895            PromptMode::Interactive,
896            false,
897        )
898        .text("Name?", None)
899        .expect_err("closed input must error");
900        assert!(err.message().contains("input closed"));
901    }
902
903    #[test]
904    fn line_text_with_shows_validator_reason_before_accepting() {
905        let reject_short = |value: &str| {
906            if value.len() >= 2 {
907                Ok(())
908            } else {
909                Err("too short".to_string())
910            }
911        };
912        let mut prompter = line_prompter(
913            ScriptedTerminal::line_driven().with_lines(["a", "ok"]),
914            PromptMode::Interactive,
915            false,
916        );
917        let value = prompter
918            .text_with("Name?", None, &reject_short)
919            .expect("valid after the reason");
920        assert_eq!(value, "ok");
921        assert!(prompter.terminal().output().contains("too short"));
922    }
923
924    // ── Construction & metadata accessors ───────────────────────────────
925
926    #[test]
927    fn from_env_builds_a_prompter_and_reports_its_mode() {
928        // Under a test harness neither stream is a TTY,
929        // so the environment prompter is non-interactive;
930        // building it also exercises terminal selection over the process streams.
931        let prompter = Prompter::from_env(ColorChoice::Never);
932        assert_eq!(prompter.mode(), PromptMode::NonInteractive);
933    }
934
935    #[test]
936    fn with_glyphs_overrides_symbols_and_preserves_mode() {
937        let prompter = line_prompter(
938            ScriptedTerminal::line_driven(),
939            PromptMode::NonInteractive,
940            false,
941        )
942        .with_glyphs(Glyphs::new(true));
943        assert_eq!(prompter.mode(), PromptMode::NonInteractive);
944    }
945}