Skip to main content

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}