use std::collections::BTreeSet;
use inquire::{MultiSelect, Select, Text};
use crate::creation::{Result, WizardError, creation_workflow::choice_description};
pub trait PromptPort {
fn prompt(&self, label: &str) -> Result<String>;
fn optional_prompt(&self, label: &str) -> Result<Option<String>>;
fn choose(&self, label: &str, choices: &[&str]) -> Result<String>;
fn choose_set(&self, label: &str, choices: &[&str], count: usize) -> Result<BTreeSet<String>>;
fn choose_set_with_descriptions(
&self,
label: &str,
choices: &[&str],
count: usize,
descriptions: bool,
) -> Result<BTreeSet<String>>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct TerminalPromptPort;
impl PromptPort for TerminalPromptPort {
fn prompt(&self, label: &str) -> Result<String> {
prompt(label)
}
fn optional_prompt(&self, label: &str) -> Result<Option<String>> {
optional_prompt(label)
}
fn choose(&self, label: &str, choices: &[&str]) -> Result<String> {
choose(label, choices)
}
fn choose_set(&self, label: &str, choices: &[&str], count: usize) -> Result<BTreeSet<String>> {
choose_set(label, choices, count)
}
fn choose_set_with_descriptions(
&self,
label: &str,
choices: &[&str],
count: usize,
descriptions: bool,
) -> Result<BTreeSet<String>> {
choose_set_with_descriptions(label, choices, count, descriptions)
}
}
pub(crate) fn prompt(label: &str) -> Result<String> {
Text::new(label)
.with_help_message("Press Esc to return to the previous step.")
.prompt()
.map_err(|_| WizardError::Back)
}
pub(crate) fn optional_prompt(label: &str) -> Result<Option<String>> {
Text::new(&format!("{label} (optional)"))
.with_help_message("Leave blank to skip; press Esc to return to the previous step.")
.prompt()
.map(|value| (!value.is_empty()).then_some(value))
.map_err(|_| WizardError::Back)
}
pub(crate) fn choose(label: &str, choices: &[&str]) -> Result<String> {
Select::new(label, options(choices, true))
.with_help_message("Type to filter; Enter selects; Esc goes back.")
.prompt()
.map(|choice| choice.value)
.map_err(|_| WizardError::Back)
}
pub(crate) fn choose_set(label: &str, choices: &[&str], count: usize) -> Result<BTreeSet<String>> {
choose_set_with_descriptions(label, choices, count, true)
}
pub(crate) fn choose_set_with_descriptions(
label: &str,
choices: &[&str],
count: usize,
descriptions: bool,
) -> Result<BTreeSet<String>> {
let prompt_label = exact_selection_label(label, count);
let help_message = format!(
"Select exactly {count}; Space toggles, Enter confirms, type filters, Esc goes back."
);
loop {
let selected = MultiSelect::new(&prompt_label, options(choices, descriptions))
.with_help_message(&help_message)
.prompt()
.map_err(|_| WizardError::Back)?;
if selected.len() == count {
return Ok(selected.into_iter().map(|choice| choice.value).collect());
}
println!(
"Select exactly {count} option(s); you selected {}.",
selected.len()
);
}
}
fn exact_selection_label(label: &str, count: usize) -> String {
let noun = if count == 1 { "option" } else { "options" };
format!("{label} (select exactly {count} {noun})")
}
#[derive(Clone)]
struct OptionLabel {
value: String,
label: String,
}
impl std::fmt::Display for OptionLabel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.label.fmt(f)
}
}
fn options(choices: &[&str], descriptions: bool) -> Vec<OptionLabel> {
choices
.iter()
.map(|choice| OptionLabel {
value: (*choice).to_owned(),
label: if descriptions {
choice_description(choice).map_or_else(
|| (*choice).to_owned(),
|description| format!("{choice} — {description}"),
)
} else {
(*choice).to_owned()
},
})
.collect()
}
#[cfg(test)]
mod tests {
use super::exact_selection_label;
#[test]
fn exact_count_is_visible_in_multi_select_labels() {
assert_eq!(
exact_selection_label("Class skills", 2),
"Class skills (select exactly 2 options)"
);
assert_eq!(
exact_selection_label("Tool proficiency", 1),
"Tool proficiency (select exactly 1 option)"
);
}
}