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> InputCollector<String> for TextPromptSource<T> {
101    fn name(&self) -> &'static str {
102        "prompt"
103    }
104
105    fn is_available(&self, _matches: &ArgMatches) -> bool {
106        self.terminal.is_terminal()
107    }
108
109    fn collect(&self, _matches: &ArgMatches) -> Result<Option<String>, InputError> {
110        if !self.terminal.is_terminal() {
111            return Ok(None);
112        }
113
114        self.terminal
115            .write_prompt(&self.prompt)
116            .map_err(|e| InputError::PromptFailed(e.to_string()))?;
117
118        let line = self
119            .terminal
120            .read_line()
121            .map_err(|e| InputError::PromptFailed(e.to_string()))?;
122
123        // Check for EOF (user pressed Ctrl+D)
124        if line.is_empty() {
125            return Err(InputError::PromptCancelled);
126        }
127
128        let result = if self.trim {
129            line.trim().to_string()
130        } else {
131            // Still need to remove trailing newline from read_line
132            line.trim_end_matches('\n')
133                .trim_end_matches('\r')
134                .to_string()
135        };
136
137        if result.is_empty() {
138            Ok(None)
139        } else {
140            Ok(Some(result))
141        }
142    }
143
144    fn can_retry(&self) -> bool {
145        true
146    }
147}
148
149/// Simple yes/no confirmation prompt.
150///
151/// Prompts the user for a yes/no response. Accepts y/yes/n/no (case-insensitive).
152///
153/// # Example
154///
155/// ```ignore
156/// use standout_input::{InputChain, FlagSource, ConfirmPromptSource};
157///
158/// let chain = InputChain::<bool>::new()
159///     .try_source(FlagSource::new("yes"))
160///     .try_source(ConfirmPromptSource::new("Proceed?"));
161///
162/// let confirmed = chain.resolve(&matches)?;
163/// ```
164#[derive(Clone)]
165pub struct ConfirmPromptSource<T: TerminalIO = RealTerminal> {
166    terminal: Arc<T>,
167    prompt: String,
168    default: Option<bool>,
169}
170
171impl ConfirmPromptSource<RealTerminal> {
172    /// Create a new confirmation prompt.
173    pub fn new(prompt: impl Into<String>) -> Self {
174        Self {
175            terminal: Arc::new(RealTerminal),
176            prompt: prompt.into(),
177            default: None,
178        }
179    }
180}
181
182impl<T: TerminalIO> ConfirmPromptSource<T> {
183    /// Create a confirm prompt with a custom terminal for testing.
184    pub fn with_terminal(prompt: impl Into<String>, terminal: T) -> Self {
185        Self {
186            terminal: Arc::new(terminal),
187            prompt: prompt.into(),
188            default: None,
189        }
190    }
191
192    /// Set a default value for when the user presses Enter without input.
193    ///
194    /// The prompt suffix will change to indicate the default:
195    /// - `None`: `[y/n]`
196    /// - `Some(true)`: `[Y/n]`
197    /// - `Some(false)`: `[y/N]`
198    pub fn default(mut self, default: bool) -> Self {
199        self.default = Some(default);
200        self
201    }
202}
203
204impl<T: TerminalIO + 'static> InputCollector<bool> for ConfirmPromptSource<T> {
205    fn name(&self) -> &'static str {
206        "prompt"
207    }
208
209    fn is_available(&self, _matches: &ArgMatches) -> bool {
210        self.terminal.is_terminal()
211    }
212
213    fn collect(&self, _matches: &ArgMatches) -> Result<Option<bool>, InputError> {
214        if !self.terminal.is_terminal() {
215            return Ok(None);
216        }
217
218        let suffix = match self.default {
219            None => "[y/n]",
220            Some(true) => "[Y/n]",
221            Some(false) => "[y/N]",
222        };
223
224        let full_prompt = format!("{} {} ", self.prompt, suffix);
225
226        self.terminal
227            .write_prompt(&full_prompt)
228            .map_err(|e| InputError::PromptFailed(e.to_string()))?;
229
230        let line = self
231            .terminal
232            .read_line()
233            .map_err(|e| InputError::PromptFailed(e.to_string()))?;
234
235        // Check for EOF
236        if line.is_empty() {
237            return Err(InputError::PromptCancelled);
238        }
239
240        let input = line.trim().to_lowercase();
241
242        if input.is_empty() {
243            // Use default if available, otherwise return None to continue chain
244            return Ok(self.default);
245        }
246
247        match input.as_str() {
248            "y" | "yes" => Ok(Some(true)),
249            "n" | "no" => Ok(Some(false)),
250            _ => {
251                // Invalid input - for non-interactive we'd fail, but prompt can retry
252                Err(InputError::ValidationFailed(
253                    "Please enter 'y' or 'n'".to_string(),
254                ))
255            }
256        }
257    }
258
259    fn can_retry(&self) -> bool {
260        true
261    }
262}
263
264/// Mock terminal for testing prompts.
265#[derive(Debug)]
266pub struct MockTerminal {
267    is_terminal: bool,
268    responses: Vec<String>,
269    /// Index of the next response to return.
270    response_index: std::sync::atomic::AtomicUsize,
271}
272
273impl Clone for MockTerminal {
274    fn clone(&self) -> Self {
275        Self {
276            is_terminal: self.is_terminal,
277            responses: self.responses.clone(),
278            response_index: std::sync::atomic::AtomicUsize::new(
279                self.response_index
280                    .load(std::sync::atomic::Ordering::SeqCst),
281            ),
282        }
283    }
284}
285
286impl MockTerminal {
287    /// Create a mock that simulates a non-terminal.
288    pub fn non_terminal() -> Self {
289        Self {
290            is_terminal: false,
291            responses: vec![],
292            response_index: std::sync::atomic::AtomicUsize::new(0),
293        }
294    }
295
296    /// Create a mock terminal that returns the given response.
297    pub fn with_response(response: impl Into<String>) -> Self {
298        Self {
299            is_terminal: true,
300            responses: vec![response.into()],
301            response_index: std::sync::atomic::AtomicUsize::new(0),
302        }
303    }
304
305    /// Create a mock terminal that returns multiple responses in sequence.
306    ///
307    /// Useful for testing retry scenarios.
308    pub fn with_responses(responses: impl IntoIterator<Item = impl Into<String>>) -> Self {
309        Self {
310            is_terminal: true,
311            responses: responses.into_iter().map(Into::into).collect(),
312            response_index: std::sync::atomic::AtomicUsize::new(0),
313        }
314    }
315
316    /// Create a mock that simulates EOF (Ctrl+D).
317    pub fn eof() -> Self {
318        Self {
319            is_terminal: true,
320            responses: vec![], // Empty vec means EOF
321            response_index: std::sync::atomic::AtomicUsize::new(0),
322        }
323    }
324}
325
326impl TerminalIO for MockTerminal {
327    fn is_terminal(&self) -> bool {
328        self.is_terminal
329    }
330
331    fn write_prompt(&self, _prompt: &str) -> io::Result<()> {
332        // Mock doesn't actually write
333        Ok(())
334    }
335
336    fn read_line(&self) -> io::Result<String> {
337        let idx = self
338            .response_index
339            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
340        if idx < self.responses.len() {
341            // Add newline like real read_line does
342            Ok(format!("{}\n", self.responses[idx]))
343        } else {
344            // EOF
345            Ok(String::new())
346        }
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use clap::Command;
354
355    fn empty_matches() -> ArgMatches {
356        Command::new("test").try_get_matches_from(["test"]).unwrap()
357    }
358
359    // === TextPromptSource tests ===
360
361    #[test]
362    fn text_prompt_unavailable_when_not_terminal() {
363        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::non_terminal());
364        assert!(!source.is_available(&empty_matches()));
365    }
366
367    #[test]
368    fn text_prompt_available_when_terminal() {
369        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("test"));
370        assert!(source.is_available(&empty_matches()));
371    }
372
373    #[test]
374    fn text_prompt_collects_input() {
375        let source =
376            TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("Alice"));
377        let result = source.collect(&empty_matches()).unwrap();
378        assert_eq!(result, Some("Alice".to_string()));
379    }
380
381    #[test]
382    fn text_prompt_trims_whitespace() {
383        let source =
384            TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("  Bob  "));
385        let result = source.collect(&empty_matches()).unwrap();
386        assert_eq!(result, Some("Bob".to_string()));
387    }
388
389    #[test]
390    fn text_prompt_no_trim() {
391        let source =
392            TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("  Bob  "))
393                .trim(false);
394        let result = source.collect(&empty_matches()).unwrap();
395        assert_eq!(result, Some("  Bob  ".to_string()));
396    }
397
398    #[test]
399    fn text_prompt_returns_none_for_empty() {
400        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response(""));
401        let result = source.collect(&empty_matches()).unwrap();
402        assert_eq!(result, None);
403    }
404
405    #[test]
406    fn text_prompt_returns_none_for_whitespace_only() {
407        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("   "));
408        let result = source.collect(&empty_matches()).unwrap();
409        assert_eq!(result, None);
410    }
411
412    #[test]
413    fn text_prompt_eof_cancels() {
414        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::eof());
415        let result = source.collect(&empty_matches());
416        assert!(matches!(result, Err(InputError::PromptCancelled)));
417    }
418
419    #[test]
420    fn text_prompt_can_retry() {
421        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("test"));
422        assert!(source.can_retry());
423    }
424
425    // === ConfirmPromptSource tests ===
426
427    #[test]
428    fn confirm_prompt_unavailable_when_not_terminal() {
429        let source = ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::non_terminal());
430        assert!(!source.is_available(&empty_matches()));
431    }
432
433    #[test]
434    fn confirm_prompt_available_when_terminal() {
435        let source =
436            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response("y"));
437        assert!(source.is_available(&empty_matches()));
438    }
439
440    #[test]
441    fn confirm_prompt_yes() {
442        for response in ["y", "Y", "yes", "YES", "Yes"] {
443            let source = ConfirmPromptSource::with_terminal(
444                "Proceed?",
445                MockTerminal::with_response(response),
446            );
447            let result = source.collect(&empty_matches()).unwrap();
448            assert_eq!(result, Some(true), "response '{}' should be true", response);
449        }
450    }
451
452    #[test]
453    fn confirm_prompt_no() {
454        for response in ["n", "N", "no", "NO", "No"] {
455            let source = ConfirmPromptSource::with_terminal(
456                "Proceed?",
457                MockTerminal::with_response(response),
458            );
459            let result = source.collect(&empty_matches()).unwrap();
460            assert_eq!(
461                result,
462                Some(false),
463                "response '{}' should be false",
464                response
465            );
466        }
467    }
468
469    #[test]
470    fn confirm_prompt_invalid_input() {
471        let source =
472            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response("maybe"));
473        let result = source.collect(&empty_matches());
474        assert!(matches!(result, Err(InputError::ValidationFailed(_))));
475    }
476
477    #[test]
478    fn confirm_prompt_empty_with_default_true() {
479        let source =
480            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response(""))
481                .default(true);
482        let result = source.collect(&empty_matches()).unwrap();
483        assert_eq!(result, Some(true));
484    }
485
486    #[test]
487    fn confirm_prompt_empty_with_default_false() {
488        let source =
489            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response(""))
490                .default(false);
491        let result = source.collect(&empty_matches()).unwrap();
492        assert_eq!(result, Some(false));
493    }
494
495    #[test]
496    fn confirm_prompt_empty_without_default() {
497        let source =
498            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response(""));
499        let result = source.collect(&empty_matches()).unwrap();
500        assert_eq!(result, None);
501    }
502
503    #[test]
504    fn confirm_prompt_eof_cancels() {
505        let source = ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::eof());
506        let result = source.collect(&empty_matches());
507        assert!(matches!(result, Err(InputError::PromptCancelled)));
508    }
509
510    #[test]
511    fn confirm_prompt_can_retry() {
512        let source =
513            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response("y"));
514        assert!(source.can_retry());
515    }
516}