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> 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 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 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#[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 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 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 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 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 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 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#[derive(Debug)]
266pub struct MockTerminal {
267 is_terminal: bool,
268 responses: Vec<String>,
269 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 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 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 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 pub fn eof() -> Self {
318 Self {
319 is_terminal: true,
320 responses: vec![], 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 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 Ok(format!("{}\n", self.responses[idx]))
343 } else {
344 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 #[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 #[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}