new: drive the wizard and verification offers through the Prompter port

The interactive half of pkh new no longer touches the terminal prompt
module directly: run() takes a Prompter, picks the wizard or the plain
resolve path through interactive(), and every select/text/confirm
question (including the verification offers) goes through the port.
Cancellations propagate as Err, preserving Ctrl+C-aborts; the summary
and vendoring-notice prints become log lines. A builder-server embed
can now drive the whole scaffold wizard over its own wire format by
implementing Prompter.
This commit is contained in:
2026-09-18 20:38:05 +02:00
parent d64e472845
commit bb76e41908
2 changed files with 81 additions and 67 deletions
+6 -2
View File
@@ -321,10 +321,14 @@ fn main() {
// the structural self-checks inside `scaffold` always run), with // the structural self-checks inside `scaffold` always run), with
// the scaffold outcome (e.g. a failed vendoring) shaping the // the scaffold outcome (e.g. a failed vendoring) shaping the
// offer. // offer.
let prompter = pkh::ui::prompt::TerminalPrompter;
if let Err(e) = rt.block_on(async { if let Err(e) = rt.block_on(async {
let opts = pkh::new::questions::run(cli).await?; let opts = pkh::new::questions::run(cli, &prompter).await?;
let outcome = pkh::new::scaffold(opts.clone(), &multi)?; let outcome = pkh::new::scaffold(opts.clone(), &multi)?;
pkh::new::questions::offer_verification(&opts, &outcome, &multi, no_verify).await; pkh::new::questions::offer_verification(
&opts, &outcome, &multi, no_verify, &prompter,
)
.await;
Ok::<(), Box<dyn std::error::Error>>(()) Ok::<(), Box<dyn std::error::Error>>(())
}) { }) {
error!("{}", e); error!("{}", e);
+75 -65
View File
@@ -1,11 +1,11 @@
//! The `pkh new` interactive wizard. //! The `pkh new` interactive wizard.
//! //!
//! [`run`] is the single entry point: on an interactive terminal it asks the //! [`run`] is the single entry point: when the prompter can interact it asks
//! questions of the spec's "Proposed UX" transcript, fills a //! the questions of the spec's "Proposed UX" transcript, fills a
//! [`NewCli`] with the answers (explicit flags are never re-asked), and //! [`NewCli`] with the answers (explicit flags are never re-asked), and
//! reuses [`options::resolve`] as the single source of truth for defaults, //! reuses [`options::resolve`] as the single source of truth for defaults,
//! detection and validation — so the non-interactive and interactive paths //! detection and validation — so the non-interactive and interactive paths
//! cannot drift apart. Without a terminal (or with `--defaults`) it goes //! cannot drift apart. Headless (or with `--defaults`) it goes
//! straight through [`options::resolve`], whose error lists every missing //! straight through [`options::resolve`], whose error lists every missing
//! answer. //! answer.
//! //!
@@ -13,11 +13,10 @@
//! verification builds of the spec ([`offer_verification`]); a failed //! verification builds of the spec ([`offer_verification`]); a failed
//! verification never undoes the scaffold. //! verification never undoes the scaffold.
//! //!
//! The prompt calls live in `run_wizard` and `offer_verification` only; //! The prompter calls live in `run_wizard` and `offer_verification` only;
//! everything else in this module is pure and unit-tested. //! everything else in this module is pure and unit-tested.
use std::error::Error; use std::error::Error;
use std::io::IsTerminal;
use std::path::PathBuf; use std::path::PathBuf;
use indicatif::MultiProgress; use indicatif::MultiProgress;
@@ -28,7 +27,7 @@ use crate::new::licenses;
use crate::new::options::{self, NewCli, NewOptions, SourceDir, SourceFormat, TemplateId}; use crate::new::options::{self, NewCli, NewOptions, SourceDir, SourceFormat, TemplateId};
use crate::new::origin::GitOrigin; use crate::new::origin::GitOrigin;
use crate::new::templates::{self, ProbeResult, ScaffoldOutcome}; use crate::new::templates::{self, ProbeResult, ScaffoldOutcome};
use crate::ui::prompt; use crate::report::{Prompter, Validator};
/// Answer of the "where is the source code?" question: fresh skeleton. /// Answer of the "where is the source code?" question: fresh skeleton.
const SOURCE_SKELETON: &str = "Create a new project skeleton here"; const SOURCE_SKELETON: &str = "Create a new project skeleton here";
@@ -40,8 +39,8 @@ const SOURCE_PATH: &str = "Package the sources in another directory…";
/// The "everything else" entry of the license menu. /// The "everything else" entry of the license menu.
const LICENSE_OTHER: &str = "Other (enter a SPDX identifier)"; const LICENSE_OTHER: &str = "Other (enter a SPDX identifier)";
/// Labels of the interactive `select` questions. `prompt::select` renders /// Labels of the interactive `select` questions. The prompter renders
/// `> <label><answer>` verbatim — unlike [`prompt::text`], it appends no /// `> <label><answer>` verbatim — unlike [`Prompter::text`], it appends no
/// formatting of its own — so each label carries its own separator: /// formatting of its own — so each label carries its own separator:
/// field-style prompts end with `": "`, question-style ones with `"? "`. /// field-style prompts end with `": "`, question-style ones with `"? "`.
const LANGUAGE_LABEL: &str = "Which language/build system is your program using? "; const LANGUAGE_LABEL: &str = "Which language/build system is your program using? ";
@@ -62,20 +61,13 @@ const SELECT_LABELS: [&str; 6] = [
ORIG_LABEL, ORIG_LABEL,
]; ];
/// Run the `pkh new` flow: the wizard on an interactive terminal, plain /// Run the `pkh new` flow: the wizard when the prompter can interact,
/// [`options::resolve`] otherwise (and with `--defaults`). /// plain [`options::resolve`] otherwise (and with `--defaults`).
pub async fn run(cli: NewCli) -> Result<NewOptions, Box<dyn Error>> { pub async fn run(cli: NewCli, prompter: &dyn Prompter) -> Result<NewOptions, Box<dyn Error>> {
if cli.defaults || !is_interactive() { if cli.defaults || !prompter.interactive() {
return Ok(options::resolve(cli).await?); return Ok(options::resolve(cli).await?);
} }
run_wizard(cli).await run_wizard(cli, prompter).await
}
/// Whether both ends of the terminal are interactive; the wizard and the
/// verification offers only run when this holds (the prompts' non-TTY
/// fallbacks would otherwise silently take defaults).
fn is_interactive() -> bool {
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
} }
/// The wizard question flow (spec "Proposed UX"), in order: /// The wizard question flow (spec "Proposed UX"), in order:
@@ -87,7 +79,10 @@ fn is_interactive() -> bool {
/// (`empty` template only), git init — then the summary screen and the /// (`empty` template only), git init — then the summary screen and the
/// final `Generate?` confirmation. Every question with an explicit flag /// final `Generate?` confirmation. Every question with an explicit flag
/// answer is skipped (flag > detected/probe > default merge order). /// answer is skipped (flag > detected/probe > default merge order).
async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> { async fn run_wizard(
mut cli: NewCli,
prompter: &dyn Prompter,
) -> Result<NewOptions, Box<dyn Error>> {
let cwd = std::env::current_dir()?; let cwd = std::env::current_dir()?;
let mut detect_dir = cli.source.clone().unwrap_or_else(|| cwd.clone()); let mut detect_dir = cli.source.clone().unwrap_or_else(|| cwd.clone());
let (detection, mut probe) = detect_and_probe(&detect_dir); let (detection, mut probe) = detect_and_probe(&detect_dir);
@@ -101,7 +96,12 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
// basename of the current directory. // basename of the current directory.
if cli.name.is_none() { if cli.name.is_none() {
let default = default_package_name(&cwd, probe.as_ref()); let default = default_package_name(&cwd, probe.as_ref());
let answer = ask_text("Package name", &default, options::validate_source_name)?; let answer = ask_text(
prompter,
"Package name",
&default,
options::validate_source_name,
)?;
cli.name = Some(answer); cli.name = Some(answer);
} }
@@ -142,7 +142,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.unwrap_or(TemplateId::EMPTY) .unwrap_or(TemplateId::EMPTY)
.display_name() .display_name()
.to_string(); .to_string();
let id = select_template(&menu, &default)?; let id = select_template(prompter, &menu, &default)?;
cli.lang = Some(id.as_str().to_string()); cli.lang = Some(id.as_str().to_string());
} }
LanguageChoice::Ambiguous(candidates) => { LanguageChoice::Ambiguous(candidates) => {
@@ -157,7 +157,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.join(", ") .join(", ")
); );
let menu = language_menu(&candidates); let menu = language_menu(&candidates);
let id = select_template(&menu, &menu[0])?; let id = select_template(prompter, &menu, &menu[0])?;
cli.lang = Some(id.as_str().to_string()); cli.lang = Some(id.as_str().to_string());
} }
} }
@@ -193,14 +193,14 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
} else { } else {
SOURCE_HERE SOURCE_HERE
}; };
let answer = select_from(SOURCE_LABEL, &options, default, |answer| { let answer = select_from(prompter, SOURCE_LABEL, &options, default, |answer| {
options.contains(&answer.to_string()) options.contains(&answer.to_string())
})?; })?;
if answer == SOURCE_HERE { if answer == SOURCE_HERE {
cli.source = Some(cwd.clone()); cli.source = Some(cwd.clone());
} else if answer == SOURCE_PATH { } else if answer == SOURCE_PATH {
let validator = |path: &str| validate_directory_answer(path); let validator = |path: &str| validate_directory_answer(path);
let path = prompt::text("Source directory", "", Some(&validator))?; let path = prompter.text("Source directory", "", Some(&validator))?;
cli.source = Some(PathBuf::from(path)); cli.source = Some(PathBuf::from(path));
} }
// SOURCE_SKELETON: cli.source stays unset (the name decides). // SOURCE_SKELETON: cli.source stays unset (the name decides).
@@ -266,7 +266,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.unwrap_or_else(|| "0.1.0".to_string()); .unwrap_or_else(|| "0.1.0".to_string());
let revision = cli.revision.unwrap_or(1); let revision = cli.revision.unwrap_or(1);
let validate = move |version: &str| options::validate_upstream_version(version, revision); let validate = move |version: &str| options::validate_upstream_version(version, revision);
let answer = ask_text("Upstream version", &default, validate)?; let answer = ask_text(prompter, "Upstream version", &default, validate)?;
cli.upstream_version = Some(answer.clone()); cli.upstream_version = Some(answer.clone());
// The typed version names an existing tag HEAD is not on: offer to // The typed version names an existing tag HEAD is not on: offer to
@@ -279,7 +279,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
"Version {answer} matches tag {tag}, but HEAD is not that \ "Version {answer} matches tag {tag}, but HEAD is not that \
tag. Check out {tag} now?" tag. Check out {tag} now?"
); );
if prompt::confirm(&question, false)? { if prompter.confirm(&question, false)? {
let dir = packaged_dir.as_deref().expect("origin implies a directory"); let dir = packaged_dir.as_deref().expect("origin implies a directory");
crate::new::origin::checkout_tag(dir, tag)?; crate::new::origin::checkout_tag(dir, tag)?;
log::info!( log::info!(
@@ -311,7 +311,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
} else { } else {
"Snapshot this working tree".to_string() "Snapshot this working tree".to_string()
}; };
let answer = select_from(ORIG_LABEL, &labels, &default, |answer| { let answer = select_from(prompter, ORIG_LABEL, &labels, &default, |answer| {
labels.contains(&answer.to_string()) labels.contains(&answer.to_string())
})?; })?;
let chosen = choices let chosen = choices
@@ -322,14 +322,14 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
cli.orig_from = Some(chosen.to_string()); cli.orig_from = Some(chosen.to_string());
if chosen == "path" { if chosen == "path" {
let validator = |path: &str| options::validate_orig_path(path); let validator = |path: &str| options::validate_orig_path(path);
let path = prompt::text("Tarball path or URL", "", Some(&validator))?; let path = prompter.text("Tarball path or URL", "", Some(&validator))?;
cli.orig_path = Some(path); cli.orig_path = Some(path);
} }
} }
// 5. Debian revision. // 5. Debian revision.
if cli.revision.is_none() { if cli.revision.is_none() {
let answer = ask_text("Debian revision", "1", validate_revision_answer)?; let answer = ask_text(prompter, "Debian revision", "1", validate_revision_answer)?;
cli.revision = answer.parse::<u32>().ok(); cli.revision = answer.parse::<u32>().ok();
} }
@@ -340,7 +340,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.and_then(|p| p.description.clone()) .and_then(|p| p.description.clone())
.unwrap_or_default(); .unwrap_or_default();
let validate = required_answer("the description"); let validate = required_answer("the description");
let answer = ask_text("One-line description", &default, validate)?; let answer = ask_text(prompter, "One-line description", &default, validate)?;
cli.description = Some(answer); cli.description = Some(answer);
} }
@@ -357,7 +357,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
options::validate_homepage(url) options::validate_homepage(url)
} }
}; };
let answer = ask_text("Homepage (blank to skip)", &default, validate)?; let answer = ask_text(prompter, "Homepage (blank to skip)", &default, validate)?;
if !answer.is_empty() { if !answer.is_empty() {
cli.homepage = Some(answer); cli.homepage = Some(answer);
} }
@@ -373,12 +373,17 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.or_else(|| detect::sniff_license(&detect_dir)); .or_else(|| detect::sniff_license(&detect_dir));
let (default, custom_default) = license_question_default(detected.as_deref()); let (default, custom_default) = license_question_default(detected.as_deref());
let options = license_menu(); let options = license_menu();
let answer = select_from(LICENSE_LABEL, &options, &default, |answer| { let answer = select_from(prompter, LICENSE_LABEL, &options, &default, |answer| {
options.contains(&answer.to_string()) options.contains(&answer.to_string())
})?; })?;
if answer == LICENSE_OTHER { if answer == LICENSE_OTHER {
let validate = required_answer("the license identifier"); let validate = required_answer("the license identifier");
let license = ask_text("License (SPDX identifier)", &custom_default, validate)?; let license = ask_text(
prompter,
"License (SPDX identifier)",
&custom_default,
validate,
)?;
cli.license = Some(license); cli.license = Some(license);
} else { } else {
cli.license = Some(answer); cli.license = Some(answer);
@@ -395,7 +400,12 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.as_ref() .as_ref()
.and_then(|p| p.command.clone()) .and_then(|p| p.command.clone())
.unwrap_or_else(|| cli.name.clone().unwrap_or_default()); .unwrap_or_else(|| cli.name.clone().unwrap_or_default());
let command = ask_text("Command name", &default, options::validate_command)?; let command = ask_text(
prompter,
"Command name",
&default,
options::validate_command,
)?;
cli.command = Some(command); cli.command = Some(command);
} }
@@ -421,13 +431,13 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
); );
} }
let validate = |answer: &str| options::parse_maintainer(answer).map(|_| ()); let validate = |answer: &str| options::parse_maintainer(answer).map(|_| ());
cli.maintainer = Some(ask_text("Maintainer", &default, validate)?); cli.maintainer = Some(ask_text(prompter, "Maintainer", &default, validate)?);
} }
// 11. Target distribution. The menu derives from the distro data pkh // 11. Target distribution. The menu derives from the distro data pkh
// ships (sorted); ubuntu is moved to the front when present so it // ships (sorted); ubuntu is moved to the front when present so it
// stays the menu's first entry and fallback default as it has always // stays the menu's first entry and fallback default as it has always
// been — prompt::select positions on a default value, not an index. // been — the selector positions on a default value, not an index.
if cli.dist.is_none() { if cli.dist.is_none() {
let vendor = crate::build::env::current_vendor().to_lowercase(); let vendor = crate::build::env::current_vendor().to_lowercase();
let mut options = crate::distro_info::supported_dists(); let mut options = crate::distro_info::supported_dists();
@@ -442,7 +452,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
} else { } else {
"ubuntu".to_string() "ubuntu".to_string()
}; };
let answer = select_from(DIST_LABEL, &options, &default, |answer| { let answer = select_from(prompter, DIST_LABEL, &options, &default, |answer| {
options.contains(&answer.to_string()) options.contains(&answer.to_string())
})?; })?;
cli.dist = Some(answer); cli.dist = Some(answer);
@@ -456,7 +466,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
if cli.series.is_none() { if cli.series.is_none() {
match crate::distro_info::get_ordered_series_name(&dist).await { match crate::distro_info::get_ordered_series_name(&dist).await {
Ok(series) if !series.is_empty() => { Ok(series) if !series.is_empty() => {
let answer = prompt::select(SERIES_LABEL, &series, &series[0])?; let answer = prompter.select(SERIES_LABEL, &series, &series[0])?;
cli.series = Some(answer); cli.series = Some(answer);
} }
_ => { _ => {
@@ -472,6 +482,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
if template == TemplateId::EMPTY && cli.depends.is_empty() { if template == TemplateId::EMPTY && cli.depends.is_empty() {
let validate = |answer: &str| options::validate_depends(answer).map(|_| ()); let validate = |answer: &str| options::validate_depends(answer).map(|_| ());
let answer = ask_text( let answer = ask_text(
prompter,
"Depends (metapackage, comma-separated, blank for an empty base)", "Depends (metapackage, comma-separated, blank for an empty base)",
"", "",
validate, validate,
@@ -494,7 +505,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
// `--no-git` already declined, or there is nothing to initialize. // `--no-git` already declined, or there is nothing to initialize.
cli.git = false; cli.git = false;
} else { } else {
cli.git = prompt::confirm("Initialize a git repository?", cli.git)?; cli.git = prompter.confirm("Initialize a git repository?", cli.git)?;
} }
// Resolve through the same pipeline as the non-interactive path: one // Resolve through the same pipeline as the non-interactive path: one
@@ -505,7 +516,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
// build resolve libraries through pkg-config? The project files prefill // build resolve libraries through pkg-config? The project files prefill
// the default (dependency() / pkg_check_modules calls found). // the default (dependency() / pkg_check_modules calls found).
if matches!(template, TemplateId::MESON | TemplateId::CMAKE) if matches!(template, TemplateId::MESON | TemplateId::CMAKE)
&& prompt::confirm( && prompter.confirm(
"Does the build resolve libraries through pkg-config (add it to Build-Depends)?", "Does the build resolve libraries through pkg-config (add it to Build-Depends)?",
pkg_config_hint(&detect_dir, template), pkg_config_hint(&detect_dir, template),
)? )?
@@ -515,7 +526,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
// Wizard-only extras (default off). // Wizard-only extras (default off).
if template != TemplateId::EMPTY if template != TemplateId::EMPTY
&& prompt::confirm( && prompter.confirm(
"Add an autopkgtest smoke test (debian/tests/control)?", "Add an autopkgtest smoke test (debian/tests/control)?",
false, false,
)? )?
@@ -523,15 +534,15 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
opts.autopkgtest = true; opts.autopkgtest = true;
} }
if let Some(watch) = watch_template(opts.homepage.as_deref()) if let Some(watch) = watch_template(opts.homepage.as_deref())
&& prompt::confirm("Add a debian/watch release watcher?", false)? && prompter.confirm("Add a debian/watch release watcher?", false)?
{ {
opts.watch = Some(watch); opts.watch = Some(watch);
} }
// Summary screen + final confirmation: Ctrl+C or 'n' abort with // Summary screen + final confirmation: Ctrl+C or 'n' abort with
// nothing written (generation is all-or-nothing later anyway). // nothing written (generation is all-or-nothing later anyway).
println!("{}", summary_text(&opts, toolchain_pin.as_deref())); log::info!("{}", summary_text(&opts, toolchain_pin.as_deref()));
if !prompt::confirm("Generate?", true)? { if !prompter.confirm("Generate?", true)? {
return Err("Aborted: nothing was written to disk.".into()); return Err("Aborted: nothing was written to disk.".into());
} }
@@ -553,6 +564,7 @@ pub async fn offer_verification(
outcome: &ScaffoldOutcome, outcome: &ScaffoldOutcome,
multi: &MultiProgress, multi: &MultiProgress,
no_verify: bool, no_verify: bool,
prompter: &dyn Prompter,
) { ) {
let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default()); let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default());
let display = crate::report::display_path(&tree); let display = crate::report::display_path(&tree);
@@ -563,9 +575,6 @@ pub async fn offer_verification(
}; };
if outcome.vendoring_failed { if outcome.vendoring_failed {
// Set apart from the surrounding success output by blank lines: a
// single warning between two success lines is easy to miss.
println!();
log::warn!( log::warn!(
"The Cargo dependencies could NOT be vendored: this package will \ "The Cargo dependencies could NOT be vendored: this package will \
not build until the vendoring is completed by hand:\n\ not build until the vendoring is completed by hand:\n\
@@ -573,10 +582,9 @@ pub async fn offer_verification(
\x20 2. add the printed source replacement to .cargo/config.toml, \ \x20 2. add the printed source replacement to .cargo/config.toml, \
plus `[net] offline = true`" plus `[net] offline = true`"
); );
println!();
} }
if no_verify || !is_interactive() { if no_verify || !prompter.interactive() {
return; return;
} }
@@ -585,7 +593,7 @@ pub async fn offer_verification(
} else { } else {
"Verify with `pkh build` now?" "Verify with `pkh build` now?"
}; };
let verify_source = match prompt::confirm(build_offer, !outcome.vendoring_failed) { let verify_source = match prompter.confirm(build_offer, !outcome.vendoring_failed) {
Ok(answer) => answer, Ok(answer) => answer,
Err(_) => return, Err(_) => return,
}; };
@@ -598,7 +606,7 @@ pub async fn offer_verification(
source: Some(tree.clone()), source: Some(tree.clone()),
options: crate::build::SourceBuildOptions::default(), options: crate::build::SourceBuildOptions::default(),
view: &*ui, view: &*ui,
prompter: &crate::ui::prompt::TerminalPrompter, prompter,
}) { }) {
log::error!("Verification source build failed: {e}"); log::error!("Verification source build failed: {e}");
log::info!( log::info!(
@@ -613,7 +621,7 @@ pub async fn offer_verification(
return; return;
} }
let verify_deb = match prompt::confirm( let verify_deb = match prompter.confirm(
"Verify with `pkh deb` now? (needs network + build deps)", "Verify with `pkh deb` now? (needs network + build deps)",
false, false,
) { ) {
@@ -797,9 +805,13 @@ fn license_question_default(probe_license: Option<&str>) -> (String, String) {
/// Ask the language question until a known template label (or CLI /// Ask the language question until a known template label (or CLI
/// identifier) is answered — the selector allows typing arbitrary text. /// identifier) is answered — the selector allows typing arbitrary text.
fn select_template(options: &[String], default: &str) -> Result<TemplateId, Box<dyn Error>> { fn select_template(
prompter: &dyn Prompter,
options: &[String],
default: &str,
) -> Result<TemplateId, Box<dyn Error>> {
loop { loop {
let answer = prompt::select(LANGUAGE_LABEL, options, default)?; let answer = prompter.select(LANGUAGE_LABEL, options, default)?;
match TemplateId::from_label(&answer) { match TemplateId::from_label(&answer) {
Some(id) => return Ok(id), Some(id) => return Ok(id),
None => log::warn!( None => log::warn!(
@@ -813,13 +825,14 @@ fn select_template(options: &[String], default: &str) -> Result<TemplateId, Box<
/// Ask a `select` question until `accept` holds for the answer (the /// Ask a `select` question until `accept` holds for the answer (the
/// selector allows typing arbitrary text, which callers may need to reject). /// selector allows typing arbitrary text, which callers may need to reject).
fn select_from( fn select_from(
prompter: &dyn Prompter,
label: &str, label: &str,
options: &[String], options: &[String],
default: &str, default: &str,
accept: impl Fn(&str) -> bool, accept: impl Fn(&str) -> bool,
) -> Result<String, Box<dyn Error>> { ) -> Result<String, Box<dyn Error>> {
loop { loop {
let answer = prompt::select(label, options, default)?; let answer = prompter.select(label, options, default)?;
if accept(&answer) { if accept(&answer) {
return Ok(answer); return Ok(answer);
} }
@@ -832,7 +845,7 @@ fn select_from(
/// upstream version like `1.0-2` carrying a Debian revision) is withheld — /// upstream version like `1.0-2` carrying a Debian revision) is withheld —
/// the question is asked without a default instead of offering one that /// the question is asked without a default instead of offering one that
/// Enter would accept verbatim. /// Enter would accept verbatim.
fn offered_default<'a>(default: &'a str, validate: &prompt::Validator) -> &'a str { fn offered_default<'a>(default: &'a str, validate: &Validator) -> &'a str {
if default.is_empty() || validate(default).is_ok() { if default.is_empty() || validate(default).is_ok() {
default default
} else { } else {
@@ -844,11 +857,7 @@ fn offered_default<'a>(default: &'a str, validate: &prompt::Validator) -> &'a st
/// `default`, and whatever answer is finally proposed — typed or the /// `default`, and whatever answer is finally proposed — typed or the
/// default — must pass `validate`. `Err` carries the validation error so the /// default — must pass `validate`. `Err` carries the validation error so the
/// caller re-asks with it. /// caller re-asks with it.
fn accept_answer( fn accept_answer(answer: &str, default: &str, validate: &Validator) -> Result<String, String> {
answer: &str,
default: &str,
validate: &prompt::Validator,
) -> Result<String, String> {
let answer = if answer.is_empty() { default } else { answer }; let answer = if answer.is_empty() { default } else { answer };
validate(answer).map(|_| answer.to_string()) validate(answer).map(|_| answer.to_string())
} }
@@ -862,6 +871,7 @@ fn accept_answer(
/// validation error) — probe data can never bypass validation and only blow /// validation error) — probe data can never bypass validation and only blow
/// up later in [`options::resolve`]. /// up later in [`options::resolve`].
fn ask_text( fn ask_text(
prompter: &dyn Prompter,
label: &str, label: &str,
default: &str, default: &str,
validate: impl Fn(&str) -> Result<(), String> + 'static, validate: impl Fn(&str) -> Result<(), String> + 'static,
@@ -883,7 +893,7 @@ fn ask_text(
} }
}; };
loop { loop {
let answer = prompt::text(label, default, Some(&accept_empty))?; let answer = prompter.text(label, default, Some(&accept_empty))?;
// Typed non-empty answers were already validated by the prompt; only // Typed non-empty answers were already validated by the prompt; only
// an empty one resolves to the default, pre-decided above. // an empty one resolves to the default, pre-decided above.
let answer = if answer.is_empty() { let answer = if answer.is_empty() {
@@ -1545,7 +1555,7 @@ mod tests {
#[test] #[test]
fn select_labels_carry_their_own_separator() { fn select_labels_carry_their_own_separator() {
// prompt::select renders `> <label><answer>` verbatim; a label // the selector renders `> <label><answer>` verbatim; a label
// without a trailing separator glues the answer to the prompt // without a trailing separator glues the answer to the prompt
// (regression: the wizard once rendered "> LicenseMIT"). // (regression: the wizard once rendered "> LicenseMIT").
for label in SELECT_LABELS { for label in SELECT_LABELS {