degenbot_cli/prompt.rs
1//! The stdin/stdout [`Prompter`] (ADR-051 D4/D2).
2//!
3//! cli-core owns the prompt *policy* as declared data (`PromptPlan`) and the
4//! *message text* each arm asks with; this module owns only the interaction:
5//! ask on stdout, read one line from stdin, and mirror `click.confirm`'s
6//! semantics - a bare Enter yields the default, anything that is not `y`/`yes`
7//! declines, and EOF declines (click raises `Abort` on a closed stdin, which
8//! the arm maps to [`CliError::Aborted`](degenbot_cli_core::CliError::Aborted)).
9//!
10//! No terminal control is used: the progress bar owns stderr, the prompt owns
11//! stdout, so the two never fight over the same draw surface (ADR-051 D9).
12
13use std::io::{BufRead, Write};
14
15use degenbot_cli_core::Prompter;
16
17/// The real stdin/stdout prompter.
18#[derive(Debug, Default, Clone, Copy)]
19pub struct ConsolePrompter;
20
21impl ConsolePrompter {
22 /// A prompter over the process's stdin/stdout.
23 #[must_use]
24 pub const fn new() -> Self {
25 Self
26 }
27}
28
29impl Prompter for ConsolePrompter {
30 fn confirm(&self, message: &str, default: bool) -> bool {
31 let stdin = std::io::stdin();
32 let mut reader = stdin.lock();
33 let stdout = std::io::stdout();
34 let mut writer = stdout.lock();
35 confirm_with(&mut reader, &mut writer, message, default)
36 }
37}
38
39/// Ask `message` on `writer` and read one line from `reader`.
40///
41/// `default` is what a bare Enter yields; a closed stdin (EOF) declines, the
42/// way `click.confirm` aborts on EOF. Split from [`ConsolePrompter`] so the
43/// interaction is unit-testable without touching the process streams.
44#[must_use]
45pub fn confirm_with<R: BufRead, W: Write>(
46 reader: &mut R,
47 writer: &mut W,
48 message: &str,
49 default: bool,
50) -> bool {
51 let suffix = if default { "[Y/n]" } else { "[y/N]" };
52 let _ = write!(writer, "{message} {suffix} ");
53 let _ = writer.flush();
54 let mut line = String::new();
55 match reader.read_line(&mut line) {
56 Ok(0) | Err(_) => false,
57 Ok(_) => match line.trim().to_ascii_lowercase().as_str() {
58 "" => default,
59 "y" | "yes" => true,
60 _ => false,
61 },
62 }
63}