1use std::io::{self, BufRead, IsTerminal, Write};
7use std::sync::Arc;
8
9use clap::ArgMatches;
10
11use crate::collector::InputCollector;
12use crate::InputError;
13
14pub trait TerminalIO: Send + Sync {
16 fn is_terminal(&self) -> bool;
18
19 fn write_prompt(&self, prompt: &str) -> io::Result<()>;
21
22 fn read_line(&self) -> io::Result<String>;
24}
25
26#[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#[derive(Clone)]
64pub struct TextPromptSource<T: TerminalIO = RealTerminal> {
65 terminal: Arc<T>,
66 prompt: String,
67 trim: bool,
68}
69
70impl TextPromptSource<RealTerminal> {
71 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 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 pub fn trim(mut self, trim: bool) -> Self {
95 self.trim = trim;
96 self
97 }
98}
99
100impl<T: TerminalIO + 'static> TextPromptSource<T> {
101 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 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 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#[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 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 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 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 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 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 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 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#[derive(Debug)]
323pub struct MockTerminal {
324 is_terminal: bool,
325 responses: Vec<String>,
326 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 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 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 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 pub fn eof() -> Self {
375 Self {
376 is_terminal: true,
377 responses: vec![], 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 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 Ok(format!("{}\n", self.responses[idx]))
400 } else {
401 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 #[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 #[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 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 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 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 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}