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