Skip to main content

pigeon/core/
wizard.rs

1use std::io::{BufRead, IsTerminal};
2
3use dialoguer::{Confirm, Password, theme::ColorfulTheme};
4
5/// One value a wizard needs, resolved either from a CLI flag or an
6/// interactive prompt. Captures a pattern that appeared five times,
7/// independently hand-written, in this codebase's job wizard before
8/// ADR-0023 (`resolve_identities`, `resolve_local_output`,
9/// `resolve_remote_output`, `resolve_concurrency`, `confirm_and_proceed`).
10pub(crate) trait WizardInput {
11    type Value;
12
13    /// `None` if the flag was omitted (fall through to `prompt`/
14    /// `non_interactive_fallback`); `Some(Err(_))` if it was given but
15    /// invalid (surfaced immediately -- an invalid flag never falls
16    /// through to an interactive prompt, e.g. an unknown identity alias).
17    fn flag_value(&self) -> Option<Result<Self::Value, String>>;
18
19    /// Interactively resolves the value; only ever called on a real TTY.
20    fn prompt(&self) -> Result<Self::Value, String>;
21
22    /// Resolves the value when the flag was omitted and stdin isn't a
23    /// terminal. `Err` for a required input (no prior default exists);
24    /// `Ok(default)` for an optional one (a safe default already existed
25    /// before this input gained a prompt).
26    fn non_interactive_fallback(&self) -> Result<Self::Value, String>;
27
28    /// The shared resolution algorithm every wizard input follows, given
29    /// once here so implementors only define the three methods above.
30    ///
31    /// `non_interactive` is the job-wide `--non-interactive` flag (ADR-0114):
32    /// when set, every input goes straight to `non_interactive_fallback()`
33    /// regardless of whether stdin is actually a TTY, so a run from an
34    /// interactive shell can still be made to never prompt.
35    fn resolve(&self, non_interactive: bool) -> Result<Self::Value, String> {
36        if let Some(result) = self.flag_value() {
37            return result;
38        }
39        if !non_interactive && std::io::stdin().is_terminal() {
40            self.prompt()
41        } else {
42            self.non_interactive_fallback()
43        }
44    }
45}
46
47/// Reads a secret. Masked and interactive on a real TTY; falls back to a
48/// plain line read from stdin otherwise, so a secret can be piped in (e.g.
49/// from a password manager).
50pub fn read_secret(prompt: &str) -> Result<String, String> {
51    if std::io::stdin().is_terminal() {
52        Password::new()
53            .with_prompt(prompt)
54            .interact()
55            .map_err(|err| format!("failed to read secret: {err}"))
56    } else {
57        let mut line = String::new();
58        std::io::stdin()
59            .lock()
60            .read_line(&mut line)
61            .map_err(|err| format!("failed to read secret from stdin: {err}"))?;
62        Ok(line.trim_end_matches(['\n', '\r']).to_string())
63    }
64}
65
66/// Asks a yes/no question. Interactive on a real TTY; falls back to reading
67/// a plain `y`/`n` line from stdin otherwise.
68pub fn confirm(prompt: &str, default: bool) -> Result<bool, String> {
69    if std::io::stdin().is_terminal() {
70        Confirm::with_theme(&ColorfulTheme::default())
71            .with_prompt(prompt)
72            .default(default)
73            .interact()
74            .map_err(|err| format!("failed to read confirmation: {err}"))
75    } else {
76        let mut line = String::new();
77        std::io::stdin()
78            .lock()
79            .read_line(&mut line)
80            .map_err(|err| format!("failed to read confirmation from stdin: {err}"))?;
81        Ok(match line.trim().to_ascii_lowercase().as_str() {
82            "y" | "yes" => true,
83            "n" | "no" => false,
84            _ => default,
85        })
86    }
87}