drep/cli/init/wizard/console.rs
1//! The wizard's view of a terminal, and the real one.
2//!
3//! Split out of `wizard.rs` because it is the seam every test replaces and it
4//! shares nothing with the flow above it: [`Console`] is three questions, and
5//! [`Terminal`] is the only implementation that touches stdin. Keeping them
6//! here leaves `wizard.rs` to the part that decides what to ask.
7
8use anyhow::{Result, anyhow};
9
10/// The terminal, or a scripted stand-in.
11pub trait Console {
12 /// Print a line.
13 fn say(&mut self, line: &str) -> Result<()>;
14
15 /// Ask a question and read one line.
16 ///
17 /// `default` is shown in the prompt and returned when the answer is empty,
18 /// so pressing Enter accepts it.
19 fn ask(&mut self, question: &str, default: Option<&str>) -> Result<String>;
20
21 /// Ask for a secret and read one line without echoing it.
22 ///
23 /// An empty answer is legitimate and means "skip", so this cannot signal
24 /// refusal by returning an error.
25 fn ask_secret(&mut self, question: &str) -> Result<String>;
26}
27
28/// The real terminal.
29///
30/// Reads lines from stdin and secrets through `rpassword`, which turns echo off
31/// for the duration of the read. A pasted key would otherwise sit in the
32/// terminal's scrollback, which is the one place drep takes care not to put a
33/// credential anywhere else - `LlmConfig`, `LlmClient` and `AuthStore` all
34/// hand-write `Debug` for the same reason.
35pub struct Terminal<'a, W: std::io::Write> {
36 out: &'a mut W,
37}
38
39impl<'a, W: std::io::Write> Terminal<'a, W> {
40 /// Wrap `out` as the wizard's console.
41 pub fn new(out: &'a mut W) -> Self {
42 Self { out }
43 }
44}
45
46impl<W: std::io::Write> Console for Terminal<'_, W> {
47 fn say(&mut self, line: &str) -> Result<()> {
48 writeln!(self.out, "{line}")?;
49 Ok(())
50 }
51
52 fn ask(&mut self, question: &str, default: Option<&str>) -> Result<String> {
53 match default {
54 Some(value) => write!(self.out, "{question} [{value}]: ")?,
55 None => write!(self.out, "{question}: ")?,
56 }
57 self.out.flush()?;
58
59 let mut line = String::new();
60 let read = std::io::stdin().read_line(&mut line)?;
61 // End of input mid-wizard. Returning the default would silently accept
62 // choices nobody made, so it is an error naming what happened.
63 if read == 0 {
64 return Err(anyhow!("input ended while `drep init` was still asking"));
65 }
66
67 let answer = line.trim();
68 Ok(match (answer.is_empty(), default) {
69 (true, Some(value)) => value.to_string(),
70 _ => answer.to_string(),
71 })
72 }
73
74 fn ask_secret(&mut self, question: &str) -> Result<String> {
75 use std::io::IsTerminal;
76
77 write!(self.out, "{question}: ")?;
78 self.out.flush()?;
79
80 // `rpassword` turns echo off on the controlling terminal, which means
81 // opening `/dev/tty` - and that fails outright when there is none,
82 // rather than degrading. A piped stdin has nothing to echo in the first
83 // place: the data is not being typed, so there is no echo to suppress
84 // and a plain read is both correct and the only thing that works.
85 let secret = if std::io::stdin().is_terminal() {
86 rpassword::read_password()?
87 } else {
88 let mut line = String::new();
89 std::io::stdin().read_line(&mut line)?;
90 line.trim_end_matches(['\n', '\r']).to_string()
91 };
92
93 // The typed newline was consumed by whichever branch ran, so the next
94 // line would otherwise start on the same row as the prompt.
95 writeln!(self.out)?;
96 Ok(secret)
97 }
98}