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}