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}