Skip to main content

standout_input/sources/
prompt.rs

1use std::io::{self, BufRead, IsTerminal, Write};
2use std::ops::ControlFlow;
3use std::sync::Arc;
4
5use clap::ArgMatches;
6
7use crate::collector::InputCollector;
8use crate::responder::PromptResponder;
9use crate::InputError;
10use crate::InputSources;
11
12pub trait TerminalIO: Send + Sync {
13    fn is_terminal(&self) -> bool;
14
15    fn write_prompt(&self, prompt: &str) -> io::Result<()>;
16
17    fn read_line(&self) -> io::Result<String>;
18}
19
20impl<T: TerminalIO + ?Sized> TerminalIO for Arc<T> {
21    fn is_terminal(&self) -> bool {
22        (**self).is_terminal()
23    }
24
25    fn write_prompt(&self, prompt: &str) -> io::Result<()> {
26        (**self).write_prompt(prompt)
27    }
28
29    fn read_line(&self) -> io::Result<String> {
30        (**self).read_line()
31    }
32}
33
34#[derive(Debug, Default, Clone, Copy)]
35pub struct RealTerminal;
36
37impl TerminalIO for RealTerminal {
38    fn is_terminal(&self) -> bool {
39        std::io::stdin().is_terminal()
40    }
41
42    fn write_prompt(&self, prompt: &str) -> io::Result<()> {
43        print!("{}", prompt);
44        io::stdout().flush()
45    }
46
47    fn read_line(&self) -> io::Result<String> {
48        let mut line = String::new();
49        io::stdin().lock().read_line(&mut line)?;
50        Ok(line)
51    }
52}
53
54#[derive(Clone)]
55pub struct TextPromptSource<T: TerminalIO = RealTerminal> {
56    terminal: Arc<T>,
57    prompt: String,
58    trim: bool,
59    responder: Option<Arc<dyn PromptResponder>>,
60}
61
62impl TextPromptSource<RealTerminal> {
63    pub fn new(prompt: impl Into<String>) -> Self {
64        Self {
65            terminal: Arc::new(RealTerminal),
66            prompt: prompt.into(),
67            trim: true,
68            responder: None,
69        }
70    }
71}
72
73impl<T: TerminalIO> TextPromptSource<T> {
74    pub fn with_terminal(prompt: impl Into<String>, terminal: T) -> Self {
75        Self {
76            terminal: Arc::new(terminal),
77            prompt: prompt.into(),
78            trim: true,
79            responder: None,
80        }
81    }
82
83    pub fn trim(mut self, trim: bool) -> Self {
84        self.trim = trim;
85        self
86    }
87}
88
89impl<T: TerminalIO + 'static> TextPromptSource<T> {
90    pub fn prompt(&self) -> Result<String, InputError> {
91        self.prompt_from(&InputSources::from_process())
92    }
93
94    pub fn prompt_from(&self, sources: &InputSources) -> Result<String, InputError> {
95        crate::collector::prompt_value_from(self, sources)
96    }
97
98    pub fn prompt_entry(&self) -> Result<Option<String>, InputError> {
99        self.prompt_entry_from(&InputSources::from_process())
100    }
101
102    pub fn prompt_entry_from(&self, sources: &InputSources) -> Result<Option<String>, InputError> {
103        match crate::responder::intercept_text(
104            crate::PromptKind::Text,
105            &self.prompt,
106            sources.responder(),
107        ) {
108            Ok(Some(value)) => return Ok(Some(value)),
109            Ok(None) => {}
110            Err(InputError::NoInput) => return Ok(None),
111            Err(error) => return Err(error),
112        }
113        let matches = crate::collector::empty_matches();
114        if !self.is_available(matches) {
115            return Ok(None);
116        }
117        Ok(Some(self.collect(matches)?.unwrap_or_default()))
118    }
119}
120
121impl<T: TerminalIO + 'static> InputCollector<String> for TextPromptSource<T> {
122    fn name(&self) -> &'static str {
123        "prompt"
124    }
125
126    fn is_available(&self, _matches: &ArgMatches) -> bool {
127        self.responder.is_some() || self.terminal.is_terminal()
128    }
129
130    fn collect(&self, _matches: &ArgMatches) -> Result<Option<String>, InputError> {
131        if let ControlFlow::Break(value) =
132            crate::responder::collect_intercept(crate::responder::intercept_text(
133                crate::PromptKind::Text,
134                &self.prompt,
135                self.responder.as_deref(),
136            ))?
137        {
138            return Ok(value);
139        }
140
141        if !self.terminal.is_terminal() {
142            return Ok(None);
143        }
144
145        self.terminal
146            .write_prompt(&self.prompt)
147            .map_err(|e| InputError::PromptFailed(e.to_string()))?;
148
149        let line = self
150            .terminal
151            .read_line()
152            .map_err(|e| InputError::PromptFailed(e.to_string()))?;
153
154        if line.is_empty() {
155            return Err(InputError::PromptCancelled);
156        }
157
158        let result = if self.trim {
159            line.trim().to_string()
160        } else {
161            line.trim_end_matches('\n')
162                .trim_end_matches('\r')
163                .to_string()
164        };
165
166        if result.is_empty() {
167            Ok(None)
168        } else {
169            Ok(Some(result))
170        }
171    }
172
173    fn bind_sources(&self, sources: &InputSources) -> Option<Box<dyn InputCollector<String>>> {
174        Some(Box::new(Self {
175            terminal: Arc::clone(&self.terminal),
176            prompt: self.prompt.clone(),
177            trim: self.trim,
178            responder: Some(sources.responder_arc()?),
179        }))
180    }
181
182    fn can_retry(&self) -> bool {
183        true
184    }
185}
186
187#[derive(Clone)]
188pub struct ConfirmPromptSource<T: TerminalIO = RealTerminal> {
189    terminal: Arc<T>,
190    prompt: String,
191    default: Option<bool>,
192    responder: Option<Arc<dyn PromptResponder>>,
193}
194
195impl ConfirmPromptSource<RealTerminal> {
196    pub fn new(prompt: impl Into<String>) -> Self {
197        Self {
198            terminal: Arc::new(RealTerminal),
199            prompt: prompt.into(),
200            default: None,
201            responder: None,
202        }
203    }
204}
205
206impl<T: TerminalIO> ConfirmPromptSource<T> {
207    pub fn with_terminal(prompt: impl Into<String>, terminal: T) -> Self {
208        Self {
209            terminal: Arc::new(terminal),
210            prompt: prompt.into(),
211            default: None,
212            responder: None,
213        }
214    }
215
216    pub fn default(mut self, default: bool) -> Self {
217        self.default = Some(default);
218        self
219    }
220}
221
222impl<T: TerminalIO + 'static> ConfirmPromptSource<T> {
223    pub fn prompt(&self) -> Result<bool, InputError> {
224        self.prompt_from(&InputSources::from_process())
225    }
226
227    pub fn prompt_from(&self, sources: &InputSources) -> Result<bool, InputError> {
228        crate::collector::prompt_value_from(self, sources)
229    }
230}
231
232impl<T: TerminalIO + 'static> InputCollector<bool> for ConfirmPromptSource<T> {
233    fn name(&self) -> &'static str {
234        "prompt"
235    }
236
237    fn is_available(&self, _matches: &ArgMatches) -> bool {
238        self.responder.is_some() || self.terminal.is_terminal()
239    }
240
241    fn collect(&self, _matches: &ArgMatches) -> Result<Option<bool>, InputError> {
242        if let ControlFlow::Break(value) =
243            crate::responder::collect_intercept(crate::responder::intercept_bool(
244                crate::PromptKind::Confirm,
245                &self.prompt,
246                self.responder.as_deref(),
247            ))?
248        {
249            return Ok(value);
250        }
251
252        if !self.terminal.is_terminal() {
253            return Ok(None);
254        }
255
256        let suffix = match self.default {
257            None => "[y/n]",
258            Some(true) => "[Y/n]",
259            Some(false) => "[y/N]",
260        };
261
262        let full_prompt = format!("{} {} ", self.prompt, suffix);
263
264        self.terminal
265            .write_prompt(&full_prompt)
266            .map_err(|e| InputError::PromptFailed(e.to_string()))?;
267
268        let line = self
269            .terminal
270            .read_line()
271            .map_err(|e| InputError::PromptFailed(e.to_string()))?;
272
273        if line.is_empty() {
274            return Err(InputError::PromptCancelled);
275        }
276
277        let input = line.trim().to_lowercase();
278
279        if input.is_empty() {
280            return Ok(self.default);
281        }
282
283        match input.as_str() {
284            "y" | "yes" => Ok(Some(true)),
285            "n" | "no" => Ok(Some(false)),
286            _ => Err(InputError::ValidationFailed(
287                "Please enter 'y' or 'n'".to_string(),
288            )),
289        }
290    }
291
292    fn bind_sources(&self, sources: &InputSources) -> Option<Box<dyn InputCollector<bool>>> {
293        Some(Box::new(Self {
294            terminal: Arc::clone(&self.terminal),
295            prompt: self.prompt.clone(),
296            default: self.default,
297            responder: Some(sources.responder_arc()?),
298        }))
299    }
300
301    fn can_retry(&self) -> bool {
302        true
303    }
304}
305
306#[derive(Debug)]
307pub struct MockTerminal {
308    is_terminal: bool,
309    responses: Vec<String>,
310    response_index: std::sync::atomic::AtomicUsize,
311}
312
313impl Clone for MockTerminal {
314    fn clone(&self) -> Self {
315        Self {
316            is_terminal: self.is_terminal,
317            responses: self.responses.clone(),
318            response_index: std::sync::atomic::AtomicUsize::new(
319                self.response_index
320                    .load(std::sync::atomic::Ordering::SeqCst),
321            ),
322        }
323    }
324}
325
326impl MockTerminal {
327    pub fn non_terminal() -> Self {
328        Self {
329            is_terminal: false,
330            responses: vec![],
331            response_index: std::sync::atomic::AtomicUsize::new(0),
332        }
333    }
334
335    pub fn with_response(response: impl Into<String>) -> Self {
336        Self {
337            is_terminal: true,
338            responses: vec![response.into()],
339            response_index: std::sync::atomic::AtomicUsize::new(0),
340        }
341    }
342
343    pub fn with_responses(responses: impl IntoIterator<Item = impl Into<String>>) -> Self {
344        Self {
345            is_terminal: true,
346            responses: responses.into_iter().map(Into::into).collect(),
347            response_index: std::sync::atomic::AtomicUsize::new(0),
348        }
349    }
350
351    pub fn eof() -> Self {
352        Self {
353            is_terminal: true,
354            responses: vec![],
355            response_index: std::sync::atomic::AtomicUsize::new(0),
356        }
357    }
358}
359
360impl TerminalIO for MockTerminal {
361    fn is_terminal(&self) -> bool {
362        self.is_terminal
363    }
364
365    fn write_prompt(&self, _prompt: &str) -> io::Result<()> {
366        Ok(())
367    }
368
369    fn read_line(&self) -> io::Result<String> {
370        let idx = self
371            .response_index
372            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
373        if idx < self.responses.len() {
374            Ok(format!("{}\n", self.responses[idx]))
375        } else {
376            Ok(String::new())
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use clap::Command;
385
386    fn empty_matches() -> ArgMatches {
387        Command::new("test").try_get_matches_from(["test"]).unwrap()
388    }
389
390    #[test]
391    fn text_prompt_unavailable_when_not_terminal() {
392        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::non_terminal());
393        assert!(!source.is_available(&empty_matches()));
394    }
395
396    #[test]
397    fn text_prompt_available_when_terminal() {
398        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("test"));
399        assert!(source.is_available(&empty_matches()));
400    }
401
402    #[test]
403    fn text_prompt_collects_input() {
404        let source =
405            TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("Alice"));
406        let result = source.collect(&empty_matches()).unwrap();
407        assert_eq!(result, Some("Alice".to_string()));
408    }
409
410    #[test]
411    fn text_prompt_trims_whitespace() {
412        let source =
413            TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("  Bob  "));
414        let result = source.collect(&empty_matches()).unwrap();
415        assert_eq!(result, Some("Bob".to_string()));
416    }
417
418    #[test]
419    fn text_prompt_no_trim() {
420        let source =
421            TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("  Bob  "))
422                .trim(false);
423        let result = source.collect(&empty_matches()).unwrap();
424        assert_eq!(result, Some("  Bob  ".to_string()));
425    }
426
427    #[test]
428    fn text_prompt_returns_none_for_empty() {
429        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response(""));
430        let result = source.collect(&empty_matches()).unwrap();
431        assert_eq!(result, None);
432    }
433
434    #[test]
435    fn text_prompt_returns_none_for_whitespace_only() {
436        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("   "));
437        let result = source.collect(&empty_matches()).unwrap();
438        assert_eq!(result, None);
439    }
440
441    #[test]
442    fn text_prompt_eof_cancels() {
443        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::eof());
444        let result = source.collect(&empty_matches());
445        assert!(matches!(result, Err(InputError::PromptCancelled)));
446    }
447
448    #[test]
449    fn text_prompt_can_retry() {
450        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("test"));
451        assert!(source.can_retry());
452    }
453
454    #[test]
455    fn confirm_prompt_unavailable_when_not_terminal() {
456        let source = ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::non_terminal());
457        assert!(!source.is_available(&empty_matches()));
458    }
459
460    #[test]
461    fn confirm_prompt_available_when_terminal() {
462        let source =
463            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response("y"));
464        assert!(source.is_available(&empty_matches()));
465    }
466
467    #[test]
468    fn confirm_prompt_yes() {
469        for response in ["y", "Y", "yes", "YES", "Yes"] {
470            let source = ConfirmPromptSource::with_terminal(
471                "Proceed?",
472                MockTerminal::with_response(response),
473            );
474            let result = source.collect(&empty_matches()).unwrap();
475            assert_eq!(result, Some(true), "response '{}' should be true", response);
476        }
477    }
478
479    #[test]
480    fn confirm_prompt_no() {
481        for response in ["n", "N", "no", "NO", "No"] {
482            let source = ConfirmPromptSource::with_terminal(
483                "Proceed?",
484                MockTerminal::with_response(response),
485            );
486            let result = source.collect(&empty_matches()).unwrap();
487            assert_eq!(
488                result,
489                Some(false),
490                "response '{}' should be false",
491                response
492            );
493        }
494    }
495
496    #[test]
497    fn confirm_prompt_invalid_input() {
498        let source =
499            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response("maybe"));
500        let result = source.collect(&empty_matches());
501        assert!(matches!(result, Err(InputError::ValidationFailed(_))));
502    }
503
504    #[test]
505    fn confirm_prompt_empty_with_default_true() {
506        let source =
507            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response(""))
508                .default(true);
509        let result = source.collect(&empty_matches()).unwrap();
510        assert_eq!(result, Some(true));
511    }
512
513    #[test]
514    fn confirm_prompt_empty_with_default_false() {
515        let source =
516            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response(""))
517                .default(false);
518        let result = source.collect(&empty_matches()).unwrap();
519        assert_eq!(result, Some(false));
520    }
521
522    #[test]
523    fn confirm_prompt_empty_without_default() {
524        let source =
525            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response(""));
526        let result = source.collect(&empty_matches()).unwrap();
527        assert_eq!(result, None);
528    }
529
530    #[test]
531    fn confirm_prompt_eof_cancels() {
532        let source = ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::eof());
533        let result = source.collect(&empty_matches());
534        assert!(matches!(result, Err(InputError::PromptCancelled)));
535    }
536
537    #[test]
538    fn confirm_prompt_can_retry() {
539        let source =
540            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response("y"));
541        assert!(source.can_retry());
542    }
543
544    use crate::{InputSources, PromptResponse, ScriptedResponder};
545    use std::sync::Arc;
546
547    fn sources_with(responder: ScriptedResponder) -> InputSources {
548        InputSources::from_process().with_responder(Arc::new(responder))
549    }
550
551    #[test]
552    fn text_prompt_shortcut_returns_value() {
553        let source =
554            TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("Carol"));
555        let value = source.prompt().unwrap();
556        assert_eq!(value, "Carol");
557    }
558
559    #[test]
560    fn text_prompt_shortcut_maps_empty_to_no_input() {
561        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("   "));
562        let err = source.prompt().unwrap_err();
563        assert!(matches!(err, InputError::NoInput));
564    }
565
566    #[test]
567    fn text_prompt_shortcut_propagates_cancel() {
568        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::eof());
569        let err = source.prompt().unwrap_err();
570        assert!(matches!(err, InputError::PromptCancelled));
571    }
572
573    #[test]
574    fn text_prompt_shortcut_skips_when_not_terminal() {
575        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::non_terminal());
576        let err = source.prompt().unwrap_err();
577        assert!(matches!(err, InputError::NoInput));
578    }
579
580    #[test]
581    fn confirm_prompt_shortcut_returns_value() {
582        let source =
583            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response("y"));
584        let value = source.prompt().unwrap();
585        assert!(value);
586    }
587
588    #[test]
589    fn confirm_prompt_shortcut_propagates_cancel() {
590        let source = ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::eof());
591        let err = source.prompt().unwrap_err();
592        assert!(matches!(err, InputError::PromptCancelled));
593    }
594
595    #[test]
596    fn confirm_prompt_shortcut_uses_default_on_empty() {
597        let source =
598            ConfirmPromptSource::with_terminal("Proceed?", MockTerminal::with_response(""))
599                .default(true);
600        let value = source.prompt().unwrap();
601        assert!(value);
602    }
603
604    #[test]
605    fn text_prompt_routes_through_responder_even_without_tty() {
606        let sources = sources_with(ScriptedResponder::new([PromptResponse::text("Ada")]));
607        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::non_terminal());
608        let value = source.prompt_from(&sources).unwrap();
609        assert_eq!(value, "Ada");
610    }
611
612    #[test]
613    fn confirm_prompt_routes_through_responder() {
614        let sources = sources_with(ScriptedResponder::new([PromptResponse::Bool(false)]));
615        let source = ConfirmPromptSource::with_terminal("OK?", MockTerminal::non_terminal());
616        let value = source.prompt_from(&sources).unwrap();
617        assert!(!value);
618    }
619
620    #[test]
621    fn bind_sources_makes_text_prompt_available_without_tty() {
622        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::non_terminal());
623        let matches = empty_matches();
624        assert!(!source.is_available(&matches));
625        let sources = sources_with(ScriptedResponder::new([PromptResponse::text("Ada")]));
626        let bound = source.bind_sources(&sources).expect("responder binds");
627        assert!(bound.is_available(&matches));
628        assert_eq!(bound.collect(&matches).unwrap(), Some("Ada".to_string()));
629    }
630
631    #[test]
632    fn bind_sources_skip_does_not_read_terminal() {
633        let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("tty"));
634        let sources = sources_with(ScriptedResponder::new([PromptResponse::Skip]));
635        let bound = source.bind_sources(&sources).expect("responder binds");
636        assert_eq!(bound.collect(&empty_matches()).unwrap(), None);
637    }
638}