This is an MVP for localizable prompts, which also supports interactive and non-interactive environments. Future work will most likely focus on enabling non-intrusive testing of code that uses these prompts, along with support for progress indicators.
JUV7C6ET4ZQJNLO7B4JB7XMLV2YUBZB4ARJHN4JEPHDGGBWGLEVAC LYZBTYIWMOD3YTMOTBJBRNVYR7JOKVVGSHCFALKLGJO3IXTJC6HQC TIPBMFLWNAATGED4B6VE7RZQJ6A4H37XH3DHK326KAZ6RL64O6OAC KDUI7LHJRRQRFYPY7ANUNXG6XCUKQ4YYOEL5NG5Y6BRMV6GQ5M7AC VZYZRAO4EXCHW2LBVFG5ELSWG5SCNDREMJ6RKQ4EKQGI2T7SD3ZQC UN2XEIEUIB4ERS3IXOHQT2GCPBKK3JKHCGEVKQFP4SCV5AONFXMQC 7M4UI3TWQIAA333GQ577HDWDWZPSZKWCYG556L6SBRLB6SZDQYPAC VQBJBFEXRTJDBH27SVWRBCBFC7OOFOZ3DSMX7PE5BIZQLGHPVDYAC UKFEFT6LSI4K7X6UHQFZYD52DILKXMZMYSO2UYS2FCHNPXIF4BEQC XGRU7WZEM6PTUCSHUA6QGNK7N34M7OPE52BTDC33BHSUEWM6B4FAC use super::macros::{impl_new, impl_with_default, impl_with_prompt};use crate::{InteractionEnvironment, InteractionError, NON_INTERACTIVE_MESSAGE};use fluent_embed::{LocalizationError, Localize};#[derive(Default)]pub struct Select {default: Option<usize>,items: Option<Vec<String>>,prompt: Option<String>,}impl_new!(Select);impl_with_default!(Select, usize);impl_with_prompt!(Select);impl Select {pub fn with_items<L: Localize>(mut self, items: &[L]) -> Result<Self, LocalizationError> {let localized_items = items.iter().map(|item| {let mut buffer = Vec::new();item.localize(&mut buffer).map(|_| buffer)}).collect::<Result<Vec<Vec<u8>>, LocalizationError>>()?.into_iter().map(|item| String::from_utf8(item).map_err(LocalizationError::InvalidOutput)).collect::<Result<Vec<String>, LocalizationError>>()?;self.items = Some(localized_items);Ok(self)}pub fn interact(self, environment: &InteractionEnvironment) -> Result<usize, InteractionError> {match environment.context {crate::InteractionContext::Terminal => {let mut prompt = dialoguer::FuzzySelect::with_theme(&*crate::THEME);if let Some(default) = self.default {prompt = prompt.default(default);}if let Some(items) = self.items {prompt = prompt.items(&items);}if let Some(prompt_text) = self.prompt {prompt = prompt.with_prompt(prompt_text);}Ok(prompt.interact()?)}crate::InteractionContext::NonInteractive => panic!("{NON_INTERACTIVE_MESSAGE}"),}}}
use super::macros::{impl_new, impl_with_prompt, impl_with_validator};use crate::{InteractionEnvironment, InteractionError, NON_INTERACTIVE_MESSAGE};use fluent_embed::{LocalizationError, Localize};struct PasswordConfirmation {prompt: String,mismatch_error: String,}#[derive(Default)]pub struct Password {confirmation: Option<PasswordConfirmation>,prompt: Option<String>,validator: Option<Box<dyn Fn(&String) -> Result<(), String>>>,}impl_new!(Password);impl_with_prompt!(Password);impl_with_validator!(Password);impl Password {pub fn with_confirmation<L1: Localize, L2: Localize>(mut self,confirmation: L1,mismatch_error: L2,) -> Result<Self, LocalizationError> {let mut buffer = Vec::new();confirmation.localize(&mut buffer)?;let confirmation_text = String::from_utf8(buffer)?;let mut buffer = Vec::new();mismatch_error.localize(&mut buffer)?;let mismatch_error_text = String::from_utf8(buffer)?;self.confirmation = Some(PasswordConfirmation {prompt: confirmation_text,mismatch_error: mismatch_error_text,});Ok(self)}pub fn interact(self,environment: &InteractionEnvironment,) -> Result<String, InteractionError> {match environment.context {crate::InteractionContext::Terminal => {let mut prompt = dialoguer::Password::with_theme(&*crate::THEME);if let Some(confirmation) = self.confirmation {prompt =prompt.with_confirmation(confirmation.prompt, confirmation.mismatch_error);}if let Some(prompt_text) = self.prompt {prompt = prompt.with_prompt(prompt_text);}if let Some(validator) = self.validator {prompt = prompt.validate_with(validator);}Ok(prompt.interact()?)}crate::InteractionContext::NonInteractive => panic!("{NON_INTERACTIVE_MESSAGE}"),}}}
mod confirm;mod input;mod macros;mod password;mod select;pub use confirm::Confirm;pub use input::Input;pub use password::Password;pub use select::Select;
macro_rules! impl_new {($newtype:ident) => {impl $newtype {pub fn new() -> Self {Self::default()}}};}macro_rules! impl_with_default {($newtype:ident, $field_type:ty) => {impl $newtype {pub fn with_default(mut self, default: $field_type) -> Self {self.default = Some(default);self}}};}macro_rules! impl_with_prompt {($newtype:ident) => {impl $newtype {pub fn with_prompt<L: Localize>(mut self,prompt: L,) -> Result<Self, LocalizationError> {let mut buffer = Vec::new();prompt.localize(&mut buffer)?;let localized_text = String::from_utf8(buffer)?;self.prompt = Some(localized_text);Ok(self)}}};}macro_rules! impl_with_validator {($newtype:ident) => {impl $newtype {pub fn with_validator<L: Localize, V: Fn(&String) -> Result<(), L> + 'static>(mut self,validator: V,) -> Self {self.validator = Some(Box::new(move |input: &String| -> Result<(), String> {match validator(input) {Ok(()) => Ok(()),Err(message) => {// Localize the error messagelet mut buffer = Vec::new();message.localize(&mut buffer).map_err(|error| error.to_string())?;Err(String::from_utf8_lossy(&buffer).to_string())}}}));self}}};}// Re-export the macro helpers for other modules to usepub(crate) use {impl_new, impl_with_default, impl_with_prompt, impl_with_validator};
use super::macros::{impl_new, impl_with_prompt, impl_with_validator};use crate::{InteractionEnvironment, InteractionError, NON_INTERACTIVE_MESSAGE};use fluent_embed::{LocalizationError, Localize};#[derive(Default)]pub struct Input {default: Option<String>,prompt: Option<String>,validator: Option<Box<dyn Fn(&String) -> Result<(), String>>>,}impl_new!(Input);impl_with_prompt!(Input);impl_with_validator!(Input);impl Input {pub fn with_default<L: Localize>(mut self, default: L) -> Result<Self, LocalizationError> {let mut buffer = Vec::new();default.localize(&mut buffer)?;let localized_text = String::from_utf8(buffer)?;self.default = Some(localized_text);Ok(self)}pub fn interact(self,environment: &InteractionEnvironment,) -> Result<String, InteractionError> {match environment.context {crate::InteractionContext::Terminal => {let mut prompt = dialoguer::Input::with_theme(&*crate::THEME);if let Some(default) = self.default {prompt = prompt.default(default);}if let Some(prompt_text) = self.prompt {prompt = prompt.with_prompt(prompt_text);}if let Some(validator) = self.validator {prompt = prompt.validate_with(validator);}Ok(prompt.interact()?)}crate::InteractionContext::NonInteractive => panic!("{NON_INTERACTIVE_MESSAGE}"),}}}
use super::macros::{impl_new, impl_with_default, impl_with_prompt};use crate::{InteractionEnvironment, InteractionError, NON_INTERACTIVE_MESSAGE};use fluent_embed::{LocalizationError, Localize};#[derive(Default)]pub struct Confirm {default: Option<bool>,prompt: Option<String>,}impl_new!(Confirm);impl_with_default!(Confirm, bool);impl_with_prompt!(Confirm);impl Confirm {pub fn interact(self, environment: &InteractionEnvironment) -> Result<bool, InteractionError> {match environment.context {crate::InteractionContext::Terminal => {let mut prompt = dialoguer::Confirm::with_theme(&*crate::THEME);if let Some(default) = self.default {prompt = prompt.default(default);}if let Some(prompt_text) = self.prompt {prompt = prompt.with_prompt(prompt_text);}Ok(prompt.interact()?)}crate::InteractionContext::NonInteractive => panic!("{NON_INTERACTIVE_MESSAGE}"),}}}
mod editor;mod prompt;use std::sync::LazyLock;use thiserror::Error;pub use editor::Editor;pub use prompt::*;static THEME: LazyLock<dialoguer::theme::ColorfulTheme> =LazyLock::new(dialoguer::theme::ColorfulTheme::default);const NON_INTERACTIVE_MESSAGE: &str = "Attempted to prompt the user in a non-interactive context";enum InteractionContext {Terminal,NonInteractive,}#[derive(Debug, Error)]pub enum InteractionError {#[error(transparent)]IO(std::io::Error),}impl From<dialoguer::Error> for InteractionError {fn from(value: dialoguer::Error) -> Self {match value {dialoguer::Error::IO(error) => InteractionError::IO(error),}}}pub struct InteractionEnvironment {context: InteractionContext,}impl InteractionEnvironment {pub fn new(interactive: bool) -> Self {Self {context: match interactive {true => InteractionContext::Terminal,false => InteractionContext::NonInteractive,},}}}
use crate::{InteractionContext, InteractionEnvironment, InteractionError, NON_INTERACTIVE_MESSAGE,};pub struct Editor {extension: Option<String>,}impl Editor {pub fn new() -> Self {Self { extension: None }}pub fn with_extension(mut self, extension: &str) -> Self {self.extension = Some(extension.to_string());self}pub fn edit(&self,environment: &InteractionEnvironment,text: &str,) -> Result<Option<String>, InteractionError> {match environment.context {InteractionContext::Terminal => {let mut editor = dialoguer::Editor::new();if let Some(extension) = &self.extension {editor.extension(extension);}Ok(editor.edit(text)?)}InteractionContext::NonInteractive => panic!("{NON_INTERACTIVE_MESSAGE}"),}}}
[package]name = "fluent_embed_interaction"version = "0.1.0"edition = "2024"[lints]workspace = true[dependencies]dialoguer.workspace = truefluent_embed = { path = "../fluent_embed" }icu_locale.workspace = truethiserror.workspace = true
name = "dialoguer"version = "0.11.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"dependencies = ["console","fuzzy-matcher","shell-words","tempfile","thiserror 1.0.69","zeroize",][[package]]
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",][[package]]name = "getrandom"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"dependencies = ["cfg-if","libc","r-efi","wasi 0.14.2+wasi-0.2.4",
"linux-raw-sys",
"linux-raw-sys 0.4.15","windows-sys",][[package]]name = "rustix"version = "1.0.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"dependencies = ["bitflags","errno","libc","linux-raw-sys 0.9.4",