From 8363c1d6fe3dfba1008852da8439a775a3846ea6 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:05:28 +0000 Subject: [PATCH 1/2] Abstract prompts behind prompt backend trait --- src/commands/encrypt.rs | 6 +- src/tui.rs | 153 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 142 insertions(+), 17 deletions(-) diff --git a/src/commands/encrypt.rs b/src/commands/encrypt.rs index 7e3870ed..a390df93 100644 --- a/src/commands/encrypt.rs +++ b/src/commands/encrypt.rs @@ -30,8 +30,10 @@ where error!("Passkey cannot be empty, try again."); continue; } - let passkey_confirm = rpassword::prompt_password("Confirm encryption passkey: ") - .map(SecretString::new)?; + let passkey_confirm = tui::prompt_secret_non_empty( + "Confirm encryption passkey: ", + "prompting for encryption passkey confirmation", + )?; if passkey1.expose_secret() == passkey_confirm.expose_secret() { passkey = Some(passkey1); break; diff --git a/src/tui.rs b/src/tui.rs index 02a1dbce..a295be09 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -11,10 +11,106 @@ use log::debug; use secrecy::SecretString; use std::collections::HashSet; use std::io::{stderr, stdout, Write}; +use std::sync::{Arc, OnceLock, RwLock}; use steamguard::Confirmation; +pub(crate) trait PromptBackend: Send + Sync { + fn prompt(&self) -> String; + fn prompt_allow_empty(&self, prompt_text: &str) -> String; + fn prompt_non_empty(&self, prompt_text: &str) -> String; + fn prompt_char(&self, text: &str, chars: &str) -> char; + fn prompt_confirmation_menu( + &self, + confirmations: Vec, + ) -> anyhow::Result<(Vec, Vec)>; + fn pause(&self); + fn prompt_secret_non_empty( + &self, + prompt_text: &str, + context: &str, + ) -> anyhow::Result; +} + +pub(crate) struct TuiPromptBackend; + +impl PromptBackend for TuiPromptBackend { + fn prompt(&self) -> String { + prompt_impl() + } + + fn prompt_allow_empty(&self, prompt_text: &str) -> String { + prompt_allow_empty_impl(prompt_text) + } + + fn prompt_non_empty(&self, prompt_text: &str) -> String { + prompt_non_empty_impl(prompt_text) + } + + fn prompt_char(&self, text: &str, chars: &str) -> char { + prompt_char_loop(text, chars) + } + + fn prompt_confirmation_menu( + &self, + confirmations: Vec, + ) -> anyhow::Result<(Vec, Vec)> { + prompt_confirmation_menu_impl(confirmations) + } + + fn pause(&self) { + pause_impl() + } + + fn prompt_secret_non_empty( + &self, + prompt_text: &str, + context: &str, + ) -> anyhow::Result { + prompt_secret_non_empty_impl(prompt_text, context) + } +} + +fn prompt_backend_cell() -> &'static RwLock> { + static PROMPT_BACKEND: OnceLock>> = OnceLock::new(); + PROMPT_BACKEND.get_or_init(|| RwLock::new(Arc::new(TuiPromptBackend))) +} + +fn prompt_backend() -> Arc { + prompt_backend_cell() + .read() + .expect("failed to read prompt backend") + .clone() +} + +#[allow(dead_code)] +pub(crate) fn with_prompt_backend( + backend: Arc, + run: impl FnOnce() -> R, +) -> R { + let previous = { + let mut current = prompt_backend_cell() + .write() + .expect("failed to write prompt backend"); + std::mem::replace(&mut *current, backend) + }; + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(run)); + *prompt_backend_cell() + .write() + .expect("failed to write prompt backend") = previous; + + match result { + Ok(value) => value, + Err(payload) => std::panic::resume_unwind(payload), + } +} + /// Prompt the user for text input. pub(crate) fn prompt() -> String { + prompt_backend().prompt() +} + +fn prompt_impl() -> String { stdout().flush().expect("failed to flush stdout"); stderr().flush().expect("failed to flush stderr"); @@ -46,14 +142,22 @@ pub(crate) fn prompt() -> String { } pub(crate) fn prompt_allow_empty(prompt_text: impl AsRef) -> String { - eprint!("{}", prompt_text.as_ref()); - prompt() + prompt_backend().prompt_allow_empty(prompt_text.as_ref()) +} + +fn prompt_allow_empty_impl(prompt_text: &str) -> String { + eprint!("{}", prompt_text); + prompt_impl() } pub(crate) fn prompt_non_empty(prompt_text: impl AsRef) -> String { + prompt_backend().prompt_non_empty(prompt_text.as_ref()) +} + +fn prompt_non_empty_impl(prompt_text: &str) -> String { loop { - eprint!("{}", prompt_text.as_ref()); - let input = prompt(); + eprint!("{}", prompt_text); + let input = prompt_impl(); if !input.is_empty() { return input; } @@ -65,10 +169,14 @@ pub(crate) fn prompt_non_empty(prompt_text: impl AsRef) -> String { /// `chars` should be all lowercase characters, with at most 1 uppercase character. The uppercase character is the default answer if no answer is provided. /// The selected character returned will always be lowercase. pub(crate) fn prompt_char(text: &str, chars: &str) -> char { + prompt_backend().prompt_char(text, chars) +} + +fn prompt_char_loop(text: &str, chars: &str) -> char { loop { let _ = stderr().queue(Print(format!("{} [{}] ", text, chars))); let _ = stderr().flush(); - let input = prompt(); + let input = prompt_impl(); if let Ok(c) = prompt_char_impl(input, chars) { return c; } @@ -109,6 +217,12 @@ fn prompt_char_impl(input: impl Into, chars: &str) -> anyhow::Result, +) -> anyhow::Result<(Vec, Vec)> { + prompt_backend().prompt_confirmation_menu(confirmations) +} + +fn prompt_confirmation_menu_impl( + confirmations: Vec, ) -> anyhow::Result<(Vec, Vec)> { if confirmations.is_empty() { return Ok((vec![], vec![])); @@ -258,6 +372,10 @@ pub(crate) fn prompt_confirmation_menu( } pub(crate) fn pause() { + prompt_backend().pause(); +} + +fn pause_impl() { let _ = write!(stderr(), "Press enter to continue..."); let _ = stderr().flush(); loop { @@ -271,25 +389,30 @@ pub(crate) fn pause() { } } -pub(crate) fn prompt_passkey() -> anyhow::Result { - debug!("prompting for passkey"); +pub(crate) fn prompt_secret_non_empty( + prompt_text: &str, + context: &str, +) -> anyhow::Result { + prompt_backend().prompt_secret_non_empty(prompt_text, context) +} + +fn prompt_secret_non_empty_impl(prompt_text: &str, context: &str) -> anyhow::Result { loop { - let raw = rpassword::prompt_password("Enter encryption passkey: ") - .context("prompting for passkey")?; + let raw = rpassword::prompt_password(prompt_text).with_context(|| context.to_owned())?; if !raw.is_empty() { return Ok(SecretString::new(raw)); } } } +pub(crate) fn prompt_passkey() -> anyhow::Result { + debug!("prompting for passkey"); + prompt_secret_non_empty("Enter encryption passkey: ", "prompting for passkey") +} + pub(crate) fn prompt_password() -> anyhow::Result { debug!("prompting for password"); - loop { - let raw = rpassword::prompt_password("Password: ").context("prompting for password")?; - if !raw.is_empty() { - return Ok(SecretString::new(raw)); - } - } + prompt_secret_non_empty("Password: ", "prompting for password") } #[cfg(test)] From fb9c608bc6f867b1a07bcf2c682b593f54edc02b Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:28:53 +0000 Subject: [PATCH 2/2] Refine prompt backend abstraction for UI parity --- src/main.rs | 1 + src/prompt_backend.rs | 23 ++++++ src/tui.rs | 168 ++++++++++++++++++++++++++---------------- 3 files changed, 129 insertions(+), 63 deletions(-) create mode 100644 src/prompt_backend.rs diff --git a/src/main.rs b/src/main.rs index d0e5efd7..cbf06c76 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ mod debug; mod encryption; mod errors; mod login; +mod prompt_backend; mod secret_string; pub(crate) mod tui; diff --git a/src/prompt_backend.rs b/src/prompt_backend.rs new file mode 100644 index 00000000..820e9ba4 --- /dev/null +++ b/src/prompt_backend.rs @@ -0,0 +1,23 @@ +use secrecy::SecretString; +use steamguard::Confirmation; + +pub(crate) struct ChoiceOption<'a> { + pub(crate) id: &'a str, + pub(crate) label: &'a str, + pub(crate) is_default: bool, +} + +pub(crate) trait PromptBackend: Send + Sync { + fn prompt_text(&self, prompt_text: &str, allow_empty: bool) -> String; + fn choose(&self, prompt_text: &str, choices: &[ChoiceOption<'_>]) -> String; + fn select_confirmations( + &self, + confirmations: Vec, + ) -> anyhow::Result<(Vec, Vec)>; + fn prompt_secret( + &self, + prompt_text: &str, + context: &str, + allow_empty: bool, + ) -> anyhow::Result; +} diff --git a/src/tui.rs b/src/tui.rs index a295be09..6070f44c 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -14,59 +14,37 @@ use std::io::{stderr, stdout, Write}; use std::sync::{Arc, OnceLock, RwLock}; use steamguard::Confirmation; -pub(crate) trait PromptBackend: Send + Sync { - fn prompt(&self) -> String; - fn prompt_allow_empty(&self, prompt_text: &str) -> String; - fn prompt_non_empty(&self, prompt_text: &str) -> String; - fn prompt_char(&self, text: &str, chars: &str) -> char; - fn prompt_confirmation_menu( - &self, - confirmations: Vec, - ) -> anyhow::Result<(Vec, Vec)>; - fn pause(&self); - fn prompt_secret_non_empty( - &self, - prompt_text: &str, - context: &str, - ) -> anyhow::Result; -} +use crate::prompt_backend::{ChoiceOption, PromptBackend}; pub(crate) struct TuiPromptBackend; impl PromptBackend for TuiPromptBackend { - fn prompt(&self) -> String { - prompt_impl() - } - - fn prompt_allow_empty(&self, prompt_text: &str) -> String { - prompt_allow_empty_impl(prompt_text) - } - - fn prompt_non_empty(&self, prompt_text: &str) -> String { - prompt_non_empty_impl(prompt_text) + fn prompt_text(&self, prompt_text: &str, allow_empty: bool) -> String { + if allow_empty { + prompt_allow_empty_impl(prompt_text) + } else { + prompt_non_empty_impl(prompt_text) + } } - fn prompt_char(&self, text: &str, chars: &str) -> char { - prompt_char_loop(text, chars) + fn choose(&self, prompt_text: &str, choices: &[ChoiceOption<'_>]) -> String { + prompt_choice_loop(prompt_text, choices) } - fn prompt_confirmation_menu( + fn select_confirmations( &self, confirmations: Vec, ) -> anyhow::Result<(Vec, Vec)> { prompt_confirmation_menu_impl(confirmations) } - fn pause(&self) { - pause_impl() - } - - fn prompt_secret_non_empty( + fn prompt_secret( &self, prompt_text: &str, context: &str, + allow_empty: bool, ) -> anyhow::Result { - prompt_secret_non_empty_impl(prompt_text, context) + prompt_secret_impl(prompt_text, context, allow_empty) } } @@ -107,7 +85,7 @@ pub(crate) fn with_prompt_backend( /// Prompt the user for text input. pub(crate) fn prompt() -> String { - prompt_backend().prompt() + prompt_backend().prompt_text("", true) } fn prompt_impl() -> String { @@ -142,7 +120,7 @@ fn prompt_impl() -> String { } pub(crate) fn prompt_allow_empty(prompt_text: impl AsRef) -> String { - prompt_backend().prompt_allow_empty(prompt_text.as_ref()) + prompt_backend().prompt_text(prompt_text.as_ref(), true) } fn prompt_allow_empty_impl(prompt_text: &str) -> String { @@ -151,7 +129,7 @@ fn prompt_allow_empty_impl(prompt_text: &str) -> String { } pub(crate) fn prompt_non_empty(prompt_text: impl AsRef) -> String { - prompt_backend().prompt_non_empty(prompt_text.as_ref()) + prompt_backend().prompt_text(prompt_text.as_ref(), false) } fn prompt_non_empty_impl(prompt_text: &str) -> String { @@ -169,56 +147,116 @@ fn prompt_non_empty_impl(prompt_text: &str) -> String { /// `chars` should be all lowercase characters, with at most 1 uppercase character. The uppercase character is the default answer if no answer is provided. /// The selected character returned will always be lowercase. pub(crate) fn prompt_char(text: &str, chars: &str) -> char { - prompt_backend().prompt_char(text, chars) + let options = parse_prompt_char_options(chars); + let choices = options + .iter() + .map(|option| ChoiceOption { + id: &option.id, + label: &option.label, + is_default: option.is_default, + }) + .collect::>(); + + let selected = prompt_backend().choose(text, &choices); + let mut chars = selected.chars(); + let answer = chars + .next() + .expect("prompt backend returned an empty choice"); + assert!( + chars.next().is_none(), + "prompt backend returned a multi-character choice" + ); + answer.to_ascii_lowercase() } -fn prompt_char_loop(text: &str, chars: &str) -> char { +fn prompt_choice_loop(text: &str, choices: &[ChoiceOption<'_>]) -> String { + let choice_labels = choices + .iter() + .map(|choice| choice.label) + .collect::>() + .join(""); + loop { - let _ = stderr().queue(Print(format!("{} [{}] ", text, chars))); + let _ = stderr().queue(Print(format!("{} [{}] ", text, choice_labels))); let _ = stderr().flush(); let input = prompt_impl(); - if let Ok(c) = prompt_char_impl(input, chars) { - return c; + if let Ok(choice) = prompt_choice_impl(input, choices) { + return choice; } } } -fn prompt_char_impl(input: impl Into, chars: &str) -> anyhow::Result { - let uppers = chars.replace(char::is_lowercase, ""); - if uppers.len() > 1 { +fn prompt_choice_impl(input: impl Into, choices: &[ChoiceOption<'_>]) -> anyhow::Result { + if choices.iter().filter(|choice| choice.is_default).count() > 1 { panic!("Invalid chars for prompt_char. Maximum 1 uppercase letter is allowed."); } - let default_answer: Option = if uppers.len() == 1 { - Some(uppers.chars().collect::>()[0].to_ascii_lowercase()) - } else { - None - }; let answer: String = input.into().to_ascii_lowercase(); if answer.is_empty() { - if let Some(a) = default_answer { - return Ok(a); + if let Some(default_choice) = choices.iter().find(|choice| choice.is_default) { + return Ok(default_choice.id.to_owned()); } else { bail!("no valid answer") } - } else if answer.len() > 1 { - bail!("answer too long") } - let answer_char = answer.chars().collect::>()[0]; - if chars.to_ascii_lowercase().contains(answer_char) { - return Ok(answer_char); + if choices + .iter() + .any(|choice| choice.id.eq_ignore_ascii_case(&answer)) + { + return Ok(answer); } bail!("no valid answer") } +struct PromptCharOption { + id: String, + label: String, + is_default: bool, +} + +fn parse_prompt_char_options(chars: &str) -> Vec { + let options = chars + .chars() + .map(|choice| PromptCharOption { + id: choice.to_ascii_lowercase().to_string(), + label: choice.to_string(), + is_default: choice.is_ascii_uppercase(), + }) + .collect::>(); + + if options.iter().filter(|option| option.is_default).count() > 1 { + panic!("Invalid chars for prompt_char. Maximum 1 uppercase letter is allowed."); + } + + options +} + +#[cfg(test)] +fn prompt_char_impl(input: impl Into, chars: &str) -> anyhow::Result { + let options = parse_prompt_char_options(chars); + let choices = options + .iter() + .map(|option| ChoiceOption { + id: &option.id, + label: &option.label, + is_default: option.is_default, + }) + .collect::>(); + let answer = prompt_choice_impl(input, &choices)?; + if answer.len() > 1 { + bail!("answer too long") + } + Ok(answer.chars().next().expect("answer should not be empty")) +} + /// Returns a tuple of (accepted, denied). Ignored confirmations are not included. pub(crate) fn prompt_confirmation_menu( confirmations: Vec, ) -> anyhow::Result<(Vec, Vec)> { - prompt_backend().prompt_confirmation_menu(confirmations) + prompt_backend().select_confirmations(confirmations) } fn prompt_confirmation_menu_impl( @@ -372,7 +410,7 @@ fn prompt_confirmation_menu_impl( } pub(crate) fn pause() { - prompt_backend().pause(); + pause_impl(); } fn pause_impl() { @@ -393,13 +431,17 @@ pub(crate) fn prompt_secret_non_empty( prompt_text: &str, context: &str, ) -> anyhow::Result { - prompt_backend().prompt_secret_non_empty(prompt_text, context) + prompt_backend().prompt_secret(prompt_text, context, false) } -fn prompt_secret_non_empty_impl(prompt_text: &str, context: &str) -> anyhow::Result { +fn prompt_secret_impl( + prompt_text: &str, + context: &str, + allow_empty: bool, +) -> anyhow::Result { loop { let raw = rpassword::prompt_password(prompt_text).with_context(|| context.to_owned())?; - if !raw.is_empty() { + if allow_empty || !raw.is_empty() { return Ok(SecretString::new(raw)); } }