Skip to main content

standout_input/sources/
prompt.rs

1//! Simple terminal prompts.
2//!
3//! Basic interactive prompts that work without external dependencies.
4//! For richer TUI prompts, use the `inquire` feature instead.
5
6use std::io::{self, BufRead, IsTerminal, Write};
7use std::sync::Arc;
8
9use clap::ArgMatches;
10
11use crate::collector::InputCollector;
12use crate::InputError;
13
14/// Abstraction over terminal I/O for testability.
15pub trait TerminalIO: Send + Sync {
16    /// Check if stdin is a terminal.
17    fn is_terminal(&self) -> bool;
18
19    /// Write a prompt to stdout.
20    fn write_prompt(&self, prompt: &str) -> io::Result<()>;
21
22    /// Read a line from stdin.
23    fn read_line(&self) -> io::Result<String>;
24}
25
26/// Real terminal I/O.
27#[derive(Debug, Default, Clone, Copy)]
28pub struct RealTerminal;
29
30impl TerminalIO for RealTerminal {
31    fn is_terminal(&self) -> bool {
32        std::io::stdin().is_terminal()
33    }
34
35    fn write_prompt(&self, prompt: &str) -> io::Result<()> {
36        print!("{}", prompt);
37        io::stdout().flush()
38    }
39
40    fn read_line(&self) -> io::Result<String> {
41        let mut line = String::new();
42        io::stdin().lock().read_line(&mut line)?;
43        Ok(line)
44    }
45}
46
47/// Simple text input prompt.
48///
49/// Prompts the user for text input in the terminal. Only available when
50/// stdin is a TTY (not piped).
51///
52/// # Example
53///
54/// ```ignore
55/// use standout_input::{InputChain, ArgSource, TextPromptSource};
56///
57/// let chain = InputChain::<String>::new()
58///     .try_source(ArgSource::new("name"))
59///     .try_source(TextPromptSource::new("Enter your name: "));
60///
61/// let name = chain.resolve(&matches)?;
62/// ```
63#[derive(Clone)]
64pub struct TextPromptSource<T: TerminalIO = RealTerminal> {
65    terminal: Arc<T>,
66    prompt: String,
67    trim: bool,
68}
69
70impl TextPromptSource<RealTerminal> {
71    /// Create a new text prompt source.
72    pub fn new(prompt: impl Into<String>) -> Self {
73        Self {
74            terminal: Arc::new(RealTerminal),
75            prompt: prompt.into(),
76            trim: true,
77        }
78    }
79}
80
81impl<T: TerminalIO> TextPromptSource<T> {
82    /// Create a text prompt with a custom terminal for testing.
83    pub fn with_terminal(prompt: impl Into<String>, terminal: T) -> Self {
84        Self {
85            terminal: Arc::new(terminal),
86            prompt: prompt.into(),
87            trim: true,
88        }
89    }
90
91    /// Control whether to trim whitespace from the input.
92    ///
93    /// Default is `true`.
94    pub fn trim(mut self, trim: bool) -> Self {
95        self.trim = trim;
96        self
97    }
98}
99
100impl<T: TerminalIO + 'static> TextPromptSource<T> {
101    /// Prompt the user for text and return the entered value.
102    ///
103    /// Standalone counterpart to [`InputCollector::collect`] for wizard /
104    /// REPL flows that drive standout themselves and have no `&ArgMatches`
105    /// to plumb through. Returns the entered text on success. Routes
106    /// through any installed
107    /// [`PromptResponder`](crate::PromptResponder).
108    ///
109    /// Errors:
110    /// - [`InputError::PromptCancelled`] on EOF (Ctrl+D)
111    /// - [`InputError::NoInput`] if stdin is not a TTY *or* the user
112    ///   submits empty input
113    /// - [`InputError::PromptFailed`] on terminal I/O failure
114    pub fn prompt(&self) -> Result<String, InputError> {
115        if let Some(value) =
116            crate::responder::intercept_text(crate::PromptKind::Text, &self.prompt)?
117        {
118            return Ok(value);
119        }
120        let matches = crate::collector::empty_matches();
121        if !self.is_available(matches) {
122            return Err(InputError::NoInput);
123        }
124        self.collect(matches)?.ok_or(InputError::NoInput)
125    }
126}
127
128impl<T: TerminalIO + 'static> InputCollector<String> for TextPromptSource<T> {
129    fn name(&self) -> &'static str {
130        "prompt"
131    }
132
133    fn is_available(&self, _matches: &ArgMatches) -> bool {
134        self.terminal.is_terminal()
135    }
136
137    fn collect(&self, _matches: &ArgMatches) -> Result<Option<String>, InputError> {
138        if !self.terminal.is_terminal() {
139            return Ok(None);
140        }
141
142        self.terminal
143            .write_prompt(&self.prompt)
144            .map_err(|e| InputError::PromptFailed(e.to_string()))?;
145
146        let line = self
147            .terminal
148            .read_line()
149            .map_err(|e| InputError::PromptFailed(e.to_string()))?;
150
151        // Check for EOF (user pressed Ctrl+D)
152        if line.is_empty() {
153            return Err(InputError::PromptCancelled);
154        }
155
156        let result = if self.trim {
157            line.trim().to_string()
158        } else {
159            // Still need to remove trailing newline from read_line
160            line.trim_end_matches('\n')
161                .trim_end_matches('\r')
162                .to_string()
163        };
164
165        if result.is_empty() {
166            Ok(None)
167        } else {
168            Ok(Some(result))
169        }
170    }
171
172    fn can_retry(&self) -> bool {
173        true
174    }
175}
176
177/// Simple yes/no confirmation prompt.
178///
179/// Prompts the user for a yes/no response. Accepts y/yes/n/no (case-insensitive).
180///
181/// # Example
182///
183/// ```ignore
184/// use standout_input::{InputChain, FlagSource, ConfirmPromptSource};
185///
186/// let chain = InputChain::<bool>::new()
187///     .try_source(FlagSource::new("yes"))
188///     .try_source(ConfirmPromptSource::new("Proceed?"));
189///
190/// let confirmed = chain.resolve(&matches)?;
191/// ```
192#[derive(Clone)]
193pub struct ConfirmPromptSource<T: TerminalIO = RealTerminal> {
194    terminal: Arc<T>,
195    prompt: String,
196    default: Option<bool>,
197}
198
199impl ConfirmPromptSource<RealTerminal> {
200    /// Create a new confirmation prompt.
201    pub fn new(prompt: impl Into<String>) -> Self {
202        Self {
203            terminal: Arc::new(RealTerminal),
204            prompt: prompt.into(),
205            default: None,
206        }
207    }
208}
209
210impl<T: TerminalIO> ConfirmPromptSource<T> {
211    /// Create a confirm prompt with a custom terminal for testing.
212    pub fn with_terminal(prompt: impl Into<String>, terminal: T) -> Self {
213        Self {
214            terminal: Arc::new(terminal),
215            prompt: prompt.into(),
216            default: None,
217        }
218    }
219
220    /// Set a default value for when the user presses Enter without input.
221    ///
222    /// The prompt suffix will change to indicate the default:
223    /// - `None`: `[y/n]`
224    /// - `Some(true)`: `[Y/n]`
225    /// - `Some(false)`: `[y/N]`
226    pub fn default(mut self, default: bool) -> Self {
227        self.default = Some(default);
228        self
229    }
230}
231
232impl<T: TerminalIO + 'static> ConfirmPromptSource<T> {
233    /// Prompt the user for a yes/no answer and return the resolved boolean.
234    ///
235    /// Standalone counterpart to [`InputCollector::collect`] for wizard /
236    /// REPL flows that drive standout themselves and have no `&ArgMatches`
237    /// to plumb through. Routes through any installed
238    /// [`PromptResponder`](crate::PromptResponder).
239    ///
240    /// Errors:
241    /// - [`InputError::PromptCancelled`] on EOF (Ctrl+D)
242    /// - [`InputError::NoInput`] if stdin is not a TTY, *or* if the user
243    ///   submits an empty line and no [`default`](Self::default) was set
244    /// - [`InputError::ValidationFailed`] if the user enters something
245    ///   that isn't a y/yes/n/no variant
246    /// - [`InputError::PromptFailed`] on terminal I/O failure
247    pub fn prompt(&self) -> Result<bool, InputError> {
248        if let Some(value) =
249            crate::responder::intercept_bool(crate::PromptKind::Confirm, &self.prompt)?
250        {
251            return Ok(value);
252        }
253        let matches = crate::collector::empty_matches();
254        if !self.is_available(matches) {
255            return Err(InputError::NoInput);
256        }
257        self.collect(matches)?.ok_or(InputError::NoInput)
258    }
259}
260
261impl<T: TerminalIO + 'static> InputCollector<bool> for ConfirmPromptSource<T> {
262    fn name(&self) -> &'static str {
263        "prompt"
264    }
265
266    fn is_available(&self, _matches: &ArgMatches) -> bool {
267        self.terminal.is_terminal()
268    }
269
270    fn collect(&self, _matches: &ArgMatches) -> Result<Option<bool>, InputError> {
271        if !self.terminal.is_terminal() {
272            return Ok(None);
273        }
274
275        let suffix = match self.default {
276            None => "[y/n]",
277            Some(true) => "[Y/n]",
278            Some(false) => "[y/N]",
279        };
280
281        let full_prompt = format!("{} {} ", self.prompt, suffix);
282
283        self.terminal
284            .write_prompt(&full_prompt)
285            .map_err(|e| InputError::PromptFailed(e.to_string()))?;
286
287        let line = self
288            .terminal
289            .read_line()
290            .map_err(|e| InputError::PromptFailed(e.to_string()))?;
291
292        // Check for EOF
293        if line.is_empty() {
294            return Err(InputError::PromptCancelled);
295        }
296
297        let input = line.trim().to_lowercase();
298
299        if input.is_empty() {
300            // Use default if available, otherwise return None to continue chain
301            return Ok(self.default);
302        }
303
304        match input.as_str() {
305            "y" | "yes" => Ok(Some(true)),
306            "n" | "no" => Ok(Some(false)),
307            _ => {
308                // Invalid input - for non-interactive we'd fail, but prompt can retry
309                Err(InputError::ValidationFailed(
310                    "Please enter 'y' or 'n'".to_string(),
311                ))
312            }
313        }
314    }
315
316    fn can_retry(&self) -> bool {
317        true
318    }
319}
320
321/// Mock terminal for testing prompts.
322#[derive(Debug)]
323pub struct MockTerminal {
324    is_terminal: bool,
325    responses: Vec<String>,
326    /// Index of the next response to return.
327    response_index: std::sync::atomic::AtomicUsize,
328}
329
330impl Clone for MockTerminal {
331    fn clone(&self) -> Self {
332        Self {
333            is_terminal: self.is_terminal,
334            responses: self.responses.clone(),
335            response_index: std::sync::atomic::AtomicUsize::new(
336                self.response_index
337                    .load(std::sync::atomic::Ordering::SeqCst),
338            ),
339        }
340    }
341}
342
343impl MockTerminal {
344    /// Create a mock that simulates a non-terminal.
345    pub fn non_terminal() -> Self {
346        Self {
347            is_terminal: false,
348            responses: vec![],
349            response_index: std::sync::atomic::AtomicUsize::new(0),
350        }
351    }
352
353    /// Create a mock terminal that returns the given response.
354    pub fn with_response(response: impl Into<String>) -> Self {
355        Self {
356            is_terminal: true,
357            responses: vec![response.into()],
358            response_index: std::sync::atomic::AtomicUsize::new(0),
359        }
360    }
361
362    /// Create a mock terminal that returns multiple responses in sequence.
363    ///
364    /// Useful for testing retry scenarios.
365    pub fn with_responses(responses: impl IntoIterator<Item = impl Into<String>>) -> Self {
366        Self {
367            is_terminal: true,
368            responses: responses.into_iter().map(Into::into).collect(),
369            response_index: std::sync::atomic::AtomicUsize::new(0),
370        }
371    }
372
373    /// Create a mock that simulates EOF (Ctrl+D).
374    pub fn eof() -> Self {
375        Self {
376            is_terminal: true,
377            responses: vec![], // Empty vec means EOF
378            response_index: std::sync::atomic::AtomicUsize::new(0),
379        }
380    }
381}
382
383impl TerminalIO for MockTerminal {
384    fn is_terminal(&self) -> bool {
385        self.is_terminal
386    }
387
388    fn write_prompt(&self, _prompt: &str) -> io::Result<()> {
389        // Mock doesn't actually write
390        Ok(())
391    }
392
393    fn read_line(&self) -> io::Result<String> {
394        let idx = self
395            .response_index
396            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
397        if idx < self.responses.len() {
398            // Add newline like real read_line does
399            Ok(format!("{}\n", self.responses[idx]))
400        } else {
401            // EOF
402            Ok(String::new())
403        }
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use clap::Command;
411
412    fn empty_matches() -> ArgMatches {
413        Command::new("test").try_get_matches_from(["test"]).unwrap()
414    }
415
416    // === TextPromptSource tests ===
417
418    #[test]
419    fn text_prompt_unavailable_when_not_terminal() {
420        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::non_terminal());
421        assert!(!source.is_available(&empty_matches()));
422    }
423
424    #[test]
425    fn text_prompt_available_when_terminal() {
426        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("test"));
427        assert!(source.is_available(&empty_matches()));
428    }
429
430    #[test]
431    fn text_prompt_collects_input() {
432        let source =
433            TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("Alice"));
434        let result = source.collect(&empty_matches()).unwrap();
435        assert_eq!(result, Some("Alice".to_string()));
436    }
437
438    #[test]
439    fn text_prompt_trims_whitespace() {
440        let source =
441            TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("  Bob  "));
442        let result = source.collect(&empty_matches()).unwrap();
443        assert_eq!(result, Some("Bob".to_string()));
444    }
445
446    #[test]
447    fn text_prompt_no_trim() {
448        let source =
449            TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("  Bob  "))
450                .trim(false);
451        let result = source.collect(&empty_matches()).unwrap();
452        assert_eq!(result, Some("  Bob  ".to_string()));
453    }
454
455    #[test]
456    fn text_prompt_returns_none_for_empty() {
457        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response(""));
458        let result = source.collect(&empty_matches()).unwrap();
459        assert_eq!(result, None);
460    }
461
462    #[test]
463    fn text_prompt_returns_none_for_whitespace_only() {
464        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("   "));
465        let result = source.collect(&empty_matches()).unwrap();
466        assert_eq!(result, None);
467    }
468
469    #[test]
470    fn text_prompt_eof_cancels() {
471        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::eof());
472        let result = source.collect(&empty_matches());
473        assert!(matches!(result, Err(InputError::PromptCancelled)));
474    }
475
476    #[test]
477    fn text_prompt_can_retry() {
478        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("test"));
479        assert!(source.can_retry());
480    }
481
482    // === ConfirmPromptSource tests ===
483
484    #[test]
485    fn confirm_prompt_unavailable_when_not_terminal() {
486        let source = ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::non_terminal());
487        assert!(!source.is_available(&empty_matches()));
488    }
489
490    #[test]
491    fn confirm_prompt_available_when_terminal() {
492        let source =
493            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response("y"));
494        assert!(source.is_available(&empty_matches()));
495    }
496
497    #[test]
498    fn confirm_prompt_yes() {
499        for response in ["y", "Y", "yes", "YES", "Yes"] {
500            let source = ConfirmPromptSource::with_terminal(
501                "Proceed?",
502                MockTerminal::with_response(response),
503            );
504            let result = source.collect(&empty_matches()).unwrap();
505            assert_eq!(result, Some(true), "response '{}' should be true", response);
506        }
507    }
508
509    #[test]
510    fn confirm_prompt_no() {
511        for response in ["n", "N", "no", "NO", "No"] {
512            let source = ConfirmPromptSource::with_terminal(
513                "Proceed?",
514                MockTerminal::with_response(response),
515            );
516            let result = source.collect(&empty_matches()).unwrap();
517            assert_eq!(
518                result,
519                Some(false),
520                "response '{}' should be false",
521                response
522            );
523        }
524    }
525
526    #[test]
527    fn confirm_prompt_invalid_input() {
528        let source =
529            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response("maybe"));
530        let result = source.collect(&empty_matches());
531        assert!(matches!(result, Err(InputError::ValidationFailed(_))));
532    }
533
534    #[test]
535    fn confirm_prompt_empty_with_default_true() {
536        let source =
537            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response(""))
538                .default(true);
539        let result = source.collect(&empty_matches()).unwrap();
540        assert_eq!(result, Some(true));
541    }
542
543    #[test]
544    fn confirm_prompt_empty_with_default_false() {
545        let source =
546            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response(""))
547                .default(false);
548        let result = source.collect(&empty_matches()).unwrap();
549        assert_eq!(result, Some(false));
550    }
551
552    #[test]
553    fn confirm_prompt_empty_without_default() {
554        let source =
555            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response(""));
556        let result = source.collect(&empty_matches()).unwrap();
557        assert_eq!(result, None);
558    }
559
560    #[test]
561    fn confirm_prompt_eof_cancels() {
562        let source = ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::eof());
563        let result = source.collect(&empty_matches());
564        assert!(matches!(result, Err(InputError::PromptCancelled)));
565    }
566
567    #[test]
568    fn confirm_prompt_can_retry() {
569        let source =
570            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response("y"));
571        assert!(source.can_retry());
572    }
573
574    // === .prompt() shortcut ===
575    //
576    // Every test that calls .prompt() shares one #[serial] axis
577    // (`prompt_responder`) because the global responder override is
578    // process-wide; without serialization a responder installed by a
579    // parallel responder-using test would leak into these vanilla
580    // shortcut tests.
581
582    use crate::{
583        reset_default_prompt_responder, set_default_prompt_responder, PromptResponse,
584        ScriptedResponder,
585    };
586    use serial_test::serial;
587    use std::sync::Arc;
588
589    #[test]
590    #[serial(prompt_responder)]
591    fn text_prompt_shortcut_returns_value() {
592        let source =
593            TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("Carol"));
594        let value = source.prompt().unwrap();
595        assert_eq!(value, "Carol");
596    }
597
598    #[test]
599    #[serial(prompt_responder)]
600    fn text_prompt_shortcut_maps_empty_to_no_input() {
601        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("   "));
602        let err = source.prompt().unwrap_err();
603        assert!(matches!(err, InputError::NoInput));
604    }
605
606    #[test]
607    #[serial(prompt_responder)]
608    fn text_prompt_shortcut_propagates_cancel() {
609        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::eof());
610        let err = source.prompt().unwrap_err();
611        assert!(matches!(err, InputError::PromptCancelled));
612    }
613
614    #[test]
615    #[serial(prompt_responder)]
616    fn text_prompt_shortcut_skips_when_not_terminal() {
617        // .prompt() should still surface NoInput when the underlying source
618        // declines (e.g. no TTY) — the wizard caller can decide what to do.
619        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::non_terminal());
620        let err = source.prompt().unwrap_err();
621        assert!(matches!(err, InputError::NoInput));
622    }
623
624    #[test]
625    #[serial(prompt_responder)]
626    fn confirm_prompt_shortcut_returns_value() {
627        let source =
628            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response("y"));
629        let value = source.prompt().unwrap();
630        assert!(value);
631    }
632
633    #[test]
634    #[serial(prompt_responder)]
635    fn confirm_prompt_shortcut_propagates_cancel() {
636        let source = ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::eof());
637        let err = source.prompt().unwrap_err();
638        assert!(matches!(err, InputError::PromptCancelled));
639    }
640
641    #[test]
642    #[serial(prompt_responder)]
643    fn confirm_prompt_shortcut_uses_default_on_empty() {
644        let source =
645            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response(""))
646                .default(true);
647        let value = source.prompt().unwrap();
648        assert!(value);
649    }
650
651    // === .prompt() via PromptResponder ===
652
653    struct ResponderGuard;
654    impl ResponderGuard {
655        fn install(responder: ScriptedResponder) -> Self {
656            set_default_prompt_responder(Arc::new(responder));
657            Self
658        }
659    }
660    impl Drop for ResponderGuard {
661        fn drop(&mut self) {
662            reset_default_prompt_responder();
663        }
664    }
665
666    #[test]
667    #[serial(prompt_responder)]
668    fn text_prompt_routes_through_responder_even_without_tty() {
669        // The non-terminal MockTerminal would normally return NoInput from
670        // prompt(); the responder gate runs *first*, so the responder wins.
671        let _g = ResponderGuard::install(ScriptedResponder::new([PromptResponse::text("Ada")]));
672        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::non_terminal());
673        let value = source.prompt().unwrap();
674        assert_eq!(value, "Ada");
675    }
676
677    #[test]
678    #[serial(prompt_responder)]
679    fn confirm_prompt_routes_through_responder() {
680        let _g = ResponderGuard::install(ScriptedResponder::new([PromptResponse::Bool(false)]));
681        let source = ConfirmPromptSource::with_terminal("OK?", MockTerminal::non_terminal());
682        let value = source.prompt().unwrap();
683        assert!(!value);
684    }
685}