This should make it much easier to run Pijul in a bunch of different contexts, and better handle scenarios such as --no-prompt
. If the changes do prove to be actually useful, they might even be worth upstreaming into the dialoguer
crate.
JTELS6L36GEOOST2SUNCJIK5TBJDNLQWCF4IRF7QSHMMVCXSSESAC
use input::{DefaultPrompt, PasswordPrompt, SelectionPrompt, TextPrompt};
use std::sync::OnceLock;
/// Global state for setting interactivity. Should be set to `Option::None`
/// if no interactivity is possible, for example running Pijul with `--no-prompt`.
static INTERACTIVE_CONTEXT: OnceLock<InteractiveContext> = OnceLock::new();
/// Get the interactive context. If not set, returns an error.
pub fn get_context() -> Result<InteractiveContext, InteractionError> {
if let Some(context) = INTERACTIVE_CONTEXT.get() {
Ok(*context)
} else {
Err(InteractionError::NoContext)
}
}
/// Set the interactive context, panicking if already set.
pub fn set_context(value: InteractiveContext) {
// There probably isn't any reason for changing contexts at runtime
INTERACTIVE_CONTEXT
.set(value)
.expect("Interactive context is already set!");
}
/// The different kinds of available prompts
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub enum PromptType {
Confirm,
Input,
Select,
Password,
}
impl core::fmt::Display for PromptType {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let name = match *self {
Self::Confirm => "confirm",
Self::Input => "input",
Self::Select => "fuzzy selection",
Self::Password => "password",
};
write!(f, "{name}")
}
}
/// Errors that can occur while attempting to interact with the user
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum InteractionError {
#[error("mode of interactivity not set")]
NoContext,
#[error("unable to provide interactivity in this context, and no valid default value for {0} prompt `{1}`")]
NotInteractive(PromptType, String),
#[error("I/O error while interacting with terminal")]
IO(#[from] std::io::Error),
}
/// Different contexts for interacting with Pijul, for example terminal or web browser
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub enum InteractiveContext {
Terminal,
NotInteractive,
}
/// A prompt that asks the user to select yes or no
pub struct Confirm(Box<dyn DefaultPrompt<bool>>);
/// A prompt that asks the user to choose from a list of items.
pub struct Select(Box<dyn SelectionPrompt<usize>>);
/// A prompt that asks the user to enter text input
pub struct Input(Box<dyn TextPrompt<String>>);
/// A prompt that asks the user to enter a password
pub struct Password(Box<dyn PasswordPrompt<String>>);
use super::{BasePrompt, InteractionError, PasswordPrompt, TextPrompt, ValidationPrompt};
use super::{DefaultPrompt, SelectionPrompt};
pub use dialoguer::{Confirm, FuzzySelect as Select, Input, Password};
use duplicate::duplicate_item;
#[duplicate_item(
handler with_generics return_type;
[Confirm] [Confirm<'_>] [bool];
[Input] [Input<'_, String>] [String];
[Select] [Select<'_>] [usize];
[Password] [Password<'_>] [String];
)]
impl BasePrompt<return_type> for with_generics {
fn set_prompt(&mut self, prompt: String) {
self.with_prompt(prompt);
}
fn interact(&mut self) -> Result<return_type, InteractionError> {
Ok(handler::interact(self)?)
}
}
#[duplicate_item(
handler with_generics return_type;
[Confirm] [Confirm<'_>] [bool];
[Input] [Input<'_, String>] [String];
[Select] [Select<'_>] [usize];
)]
impl DefaultPrompt<return_type> for with_generics {
fn set_default(&mut self, value: return_type) {
self.default(value);
}
}
impl SelectionPrompt<usize> for Select<'_> {
fn add_items(&mut self, items: &[String]) {
Select::items(self, items);
}
}
impl ValidationPrompt<String> for Input<'_, String> {
fn set_validator(&mut self, validator: Box<dyn Fn(&String) -> Result<(), String>>) {
self.validate_with(validator);
}
}
impl ValidationPrompt<String> for Password<'_> {
fn set_validator(&mut self, validator: Box<dyn Fn(&String) -> Result<(), String>>) {
self.validate_with(validator);
}
}
impl PasswordPrompt<String> for Password<'_> {
fn set_confirmation(&mut self, confirm_prompt: String, mismatch_err: String) {
self.with_confirmation(confirm_prompt, mismatch_err);
}
}
impl TextPrompt<String> for Input<'_, String> {}
use super::{
BasePrompt, DefaultPrompt, InteractionError, PasswordPrompt, PromptType, SelectionPrompt,
TextPrompt, ValidationPrompt,
};
use core::fmt::Debug;
use log::{error, info, warn};
/// Holds state for non-interactive contexts so that non-interactive contexts
/// such as `pijul XXX --no-prompt` can use the same interface, and to produce
/// nicer debugging output.
pub struct PseudoInteractive<T: Clone + Debug> {
prompt_type: PromptType,
prompt: Option<String>,
default: Option<T>,
items: Vec<String>,
validator: Option<Box<dyn Fn(&T) -> Result<(), String>>>,
confirmation: Option<(String, String)>,
}
impl<T: Clone + Debug> PseudoInteractive<T> {
pub fn new(prompt_type: PromptType) -> Self {
Self {
prompt_type,
prompt: None,
default: None,
items: Vec::new(),
validator: None,
confirmation: None,
}
}
}
impl<T: Clone + Debug> BasePrompt<T> for PseudoInteractive<T> {
fn set_prompt(&mut self, prompt: String) {
self.prompt = Some(prompt);
}
fn interact(&mut self) -> Result<T, InteractionError> {
let prompt = self
.prompt
.clone()
.unwrap_or_else(|| "[NO PROMPT SET]".to_owned());
if let Some(default) = self.default.as_mut() {
warn!(
"Non-interactive context. The {:?} prompt `{prompt}` will default to {default:#?} .",
self.prompt_type
);
if let Some(validator) = self.validator.as_mut() {
warn!(
"Non-interactive context. The {:?} prompt `{prompt}` will default to {default:#?} if valid.",
self.prompt_type
);
match validator(default) {
Ok(_) => {
info!("Default value passed validation.");
Ok(default.to_owned())
}
Err(err) => {
error!("Default value failed validation: {err}");
Err(InteractionError::NotInteractive(self.prompt_type, prompt))
}
}
} else {
warn!(
"Non-interactive context. The {:?} prompt `{prompt}` will default to {default:#?}.",
self.prompt_type
);
Ok(default.to_owned())
}
} else {
error!("No default value found.");
Err(InteractionError::NotInteractive(self.prompt_type, prompt))
}
}
}
impl<T: Clone + Debug> DefaultPrompt<T> for PseudoInteractive<T> {
fn set_default(&mut self, value: T) {
self.default = Some(value);
}
}
impl<T: Clone + Debug> SelectionPrompt<T> for PseudoInteractive<T> {
fn add_items(&mut self, items: &[String]) {
self.items = Vec::from(items);
}
}
impl<T: Clone + Debug> ValidationPrompt<T> for PseudoInteractive<T> {
fn set_validator(&mut self, validator: Box<dyn Fn(&T) -> Result<(), String>>) {
self.validator = Some(validator);
}
}
impl<T: Clone + Debug> PasswordPrompt<T> for PseudoInteractive<T> {
fn set_confirmation(&mut self, confirm_prompt: String, mismatch_err: String) {
self.confirmation = Some((confirm_prompt, mismatch_err));
}
}
impl<T: Clone + Debug> TextPrompt<T> for PseudoInteractive<T> {}
//! Implement the various prompt types defined in `lib.rs`
mod non_interactive;
mod terminal;
use crate::{Confirm, Input, Password, Select};
use crate::{InteractionError, InteractiveContext, PromptType};
use dialoguer::theme;
use duplicate::duplicate_item;
use lazy_static::lazy_static;
use non_interactive::PseudoInteractive;
lazy_static! {
static ref THEME: Box<dyn theme::Theme + Send + Sync> = {
use dialoguer::theme;
use pijul_config::{self as config, Choice};
if let Ok((config, _)) = config::Global::load() {
let color_choice = config.colors.unwrap_or_default();
match color_choice {
Choice::Auto | Choice::Always => Box::<theme::ColorfulTheme>::default(),
Choice::Never => Box::new(theme::SimpleTheme),
}
} else {
Box::<theme::ColorfulTheme>::default()
}
};
}
/// A common interface shared by every prompt type.
/// May be useful if you wish to abstract over different kinds of prompt.
pub trait BasePrompt<T> {
fn set_prompt(&mut self, prompt: String);
fn interact(&mut self) -> Result<T, InteractionError>;
}
/// A trait for prompts that allow a default selection.
pub trait DefaultPrompt<T>: BasePrompt<T> {
fn set_default(&mut self, value: T);
}
/// A trait for prompts that may need validation of user input.
///
/// This is mostly useful in contexts such as plain-text input or passwords,
/// rather than on controlled input such as confirmation prompts.
pub trait ValidationPrompt<T>: BasePrompt<T> {
fn set_validator(&mut self, validator: Box<dyn Fn(&T) -> Result<(), String>>);
}
/// A trait for prompts that accept a password.
pub trait PasswordPrompt<T>: ValidationPrompt<T> {
fn set_confirmation(&mut self, confirm_prompt: String, mismatch_err: String);
}
/// A trait for prompts that accept text with a default value.
/// Notably, this does NOT include passwords.
pub trait TextPrompt<T>: ValidationPrompt<T> + DefaultPrompt<T> {}
/// A trait for prompts where the user may choose from a selection of items.
pub trait SelectionPrompt<T>: DefaultPrompt<T> {
fn add_items(&mut self, items: &[String]);
}
#[duplicate_item(
handler prompt_type return_type;
[Confirm] [PromptType::Confirm] [bool];
[Input] [PromptType::Input] [String];
[Select] [PromptType::Select] [usize];
[Password] [PromptType::Password] [String];
)]
impl handler {
/// Create the prompt, returning an error if interactive context is incorrectly set.
pub fn new() -> Result<Self, InteractionError> {
Ok(Self(match crate::get_context()? {
InteractiveContext::Terminal => Box::new(terminal::handler::with_theme(THEME.as_ref())),
InteractiveContext::NotInteractive => Box::new(PseudoInteractive::new(prompt_type)),
}))
}
/// Set the prompt.
pub fn set_prompt(&mut self, prompt: String) {
self.0.set_prompt(prompt);
}
/// Builder pattern for [`Self::set_prompt`]
pub fn with_prompt<S: ToString>(&mut self, prompt: S) -> &mut Self {
self.set_prompt(prompt.to_string());
self
}
/// Present the prompt to the user. May return an error if in a non-interactive context,
/// or interaction fails for any other reason
pub fn interact(&mut self) -> Result<return_type, InteractionError> {
self.0.interact()
}
}
#[duplicate_item(
handler return_type;
[Confirm] [bool];
[Input] [String];
[Select] [usize];
)]
impl handler {
/// Set the default selection. If the user does not input anything, this value will be used instead.
pub fn set_default(&mut self, value: return_type) {
self.0.set_default(value);
}
/// Builder pattern for [`Self::set_default`]
pub fn with_default<I: Into<return_type>>(&mut self, value: I) -> &mut Self {
self.set_default(value.into());
self
}
}
// TODO: check if clippy catches Into<String> -> ToString
impl Select {
/// Add items to be displayed in the selection prompt.
pub fn add_items<S: ToString>(&mut self, items: &[S]) {
let string_items: Vec<String> = items.iter().map(ToString::to_string).collect();
self.0.add_items(string_items.as_slice());
}
/// Builder pattern for [`Self::add_items`].
///
/// NOTE: if this function is called multiple times, it will add ALL items to the builder.
pub fn with_items<S: ToString>(&mut self, items: &[S]) -> &mut Self {
self.add_items(items);
self
}
}
impl Password {
/// Ask the user to confirm the password with the provided prompt & error message.
pub fn set_confirmation<S: ToString>(&mut self, confirm_prompt: S, mismatch_err: S) {
self.0
.set_confirmation(confirm_prompt.to_string(), mismatch_err.to_string());
}
/// Builder pattern for [`Self::set_confirmation`]
pub fn with_confirmation<S: ToString>(
&mut self,
confirm_prompt: S,
mismatch_err: S,
) -> &mut Self {
self.set_confirmation(confirm_prompt, mismatch_err);
self
}
}
#[duplicate_item(
handler prompt_type;
[Input] [PromptType::Input];
[Password] [PromptType::Password];
)]
impl handler {
/// Set a validator to be run on input. If the validator returns [`Ok`], the input will be deemed
/// valid. If the validator returns [`Err`], the prompt will display the error message
pub fn set_validator<V, E>(&mut self, validator: V)
where
V: Fn(&String) -> Result<(), E> + 'static,
E: ToString,
{
self.0
.set_validator(Box::new(move |input| match validator(input) {
Ok(()) => Ok(()),
Err(e) => Err(e.to_string()),
}));
}
/// Builder pattern for [`Self::set_validator`]
pub fn with_validator<V, E>(&mut self, validator: V) -> &mut Self
where
V: Fn(&String) -> Result<(), E> + 'static,
E: ToString,
{
self.set_validator(validator);
self
}
}
log = "0.4.19"
thiserror = "1.0.43"
pijul-config = { path = "../pijul-config" }