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 message
let 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 use
pub(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 = true
fluent_embed = { path = "../fluent_embed" }
icu_locale.workspace = true
thiserror.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",