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 scaffold outcome (e.g. a failed vendoring) shaping the
// offer.
let prompter = pkh::ui::prompt::TerminalPrompter;
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)?;
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>>(())
}) {
error!("{}", e);
+75 -65
View File
@@ -1,11 +1,11 @@
//! The `pkh new` interactive wizard.
//!
//! [`run`] is the single entry point: on an interactive terminal it asks the
//! questions of the spec's "Proposed UX" transcript, fills a
//! [`run`] is the single entry point: when the prompter can interact it asks
//! the questions of the spec's "Proposed UX" transcript, fills a
//! [`NewCli`] with the answers (explicit flags are never re-asked), and
//! reuses [`options::resolve`] as the single source of truth for defaults,
//! 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
//! answer.
//!
@@ -13,11 +13,10 @@
//! verification builds of the spec ([`offer_verification`]); a failed
//! 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.
use std::error::Error;
use std::io::IsTerminal;
use std::path::PathBuf;
use indicatif::MultiProgress;
@@ -28,7 +27,7 @@ use crate::new::licenses;
use crate::new::options::{self, NewCli, NewOptions, SourceDir, SourceFormat, TemplateId};
use crate::new::origin::GitOrigin;
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.
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.
const LICENSE_OTHER: &str = "Other (enter a SPDX identifier)";
/// Labels of the interactive `select` questions. `prompt::select` renders
/// `> <label><answer>` verbatim — unlike [`prompt::text`], it appends no
/// Labels of the interactive `select` questions. The prompter renders
/// `> <label><answer>` verbatim — unlike [`Prompter::text`], it appends no
/// formatting of its own — so each label carries its own separator:
/// field-style prompts end with `": "`, question-style ones with `"? "`.
const LANGUAGE_LABEL: &str = "Which language/build system is your program using? ";
@@ -62,20 +61,13 @@ const SELECT_LABELS: [&str; 6] = [
ORIG_LABEL,
];
/// Run the `pkh new` flow: the wizard on an interactive terminal, plain
/// [`options::resolve`] otherwise (and with `--defaults`).
pub async fn run(cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
if cli.defaults || !is_interactive() {
/// Run the `pkh new` flow: the wizard when the prompter can interact,
/// plain [`options::resolve`] otherwise (and with `--defaults`).
pub async fn run(cli: NewCli, prompter: &dyn Prompter) -> Result<NewOptions, Box<dyn Error>> {
if cli.defaults || !prompter.interactive() {
return Ok(options::resolve(cli).await?);
}
run_wizard(cli).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()
run_wizard(cli, prompter).await
}
/// 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
/// final `Generate?` confirmation. Every question with an explicit flag
/// 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 mut detect_dir = cli.source.clone().unwrap_or_else(|| cwd.clone());
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.
if cli.name.is_none() {
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);
}
@@ -142,7 +142,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.unwrap_or(TemplateId::EMPTY)
.display_name()
.to_string();
let id = select_template(&menu, &default)?;
let id = select_template(prompter, &menu, &default)?;
cli.lang = Some(id.as_str().to_string());
}
LanguageChoice::Ambiguous(candidates) => {
@@ -157,7 +157,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.join(", ")
);
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());
}
}
@@ -193,14 +193,14 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
} else {
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())
})?;
if answer == SOURCE_HERE {
cli.source = Some(cwd.clone());
} else if answer == SOURCE_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));
}
// 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());
let revision = cli.revision.unwrap_or(1);
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());
// 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 \
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");
crate::new::origin::checkout_tag(dir, tag)?;
log::info!(
@@ -311,7 +311,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
} else {
"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())
})?;
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());
if chosen == "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);
}
}
// 5. Debian revision.
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();
}
@@ -340,7 +340,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.and_then(|p| p.description.clone())
.unwrap_or_default();
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);
}
@@ -357,7 +357,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
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() {
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));
let (default, custom_default) = license_question_default(detected.as_deref());
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())
})?;
if answer == LICENSE_OTHER {
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);
} else {
cli.license = Some(answer);
@@ -395,7 +400,12 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.as_ref()
.and_then(|p| p.command.clone())
.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);
}
@@ -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(|_| ());
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
// 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
// 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() {
let vendor = crate::build::env::current_vendor().to_lowercase();
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 {
"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())
})?;
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() {
match crate::distro_info::get_ordered_series_name(&dist).await {
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);
}
_ => {
@@ -472,6 +482,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
if template == TemplateId::EMPTY && cli.depends.is_empty() {
let validate = |answer: &str| options::validate_depends(answer).map(|_| ());
let answer = ask_text(
prompter,
"Depends (metapackage, comma-separated, blank for an empty base)",
"",
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.
cli.git = false;
} 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
@@ -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
// the default (dependency() / pkg_check_modules calls found).
if matches!(template, TemplateId::MESON | TemplateId::CMAKE)
&& prompt::confirm(
&& prompter.confirm(
"Does the build resolve libraries through pkg-config (add it to Build-Depends)?",
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).
if template != TemplateId::EMPTY
&& prompt::confirm(
&& prompter.confirm(
"Add an autopkgtest smoke test (debian/tests/control)?",
false,
)?
@@ -523,15 +534,15 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
opts.autopkgtest = true;
}
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);
}
// Summary screen + final confirmation: Ctrl+C or 'n' abort with
// nothing written (generation is all-or-nothing later anyway).
println!("{}", summary_text(&opts, toolchain_pin.as_deref()));
if !prompt::confirm("Generate?", true)? {
log::info!("{}", summary_text(&opts, toolchain_pin.as_deref()));
if !prompter.confirm("Generate?", true)? {
return Err("Aborted: nothing was written to disk.".into());
}
@@ -553,6 +564,7 @@ pub async fn offer_verification(
outcome: &ScaffoldOutcome,
multi: &MultiProgress,
no_verify: bool,
prompter: &dyn Prompter,
) {
let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default());
let display = crate::report::display_path(&tree);
@@ -563,9 +575,6 @@ pub async fn offer_verification(
};
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!(
"The Cargo dependencies could NOT be vendored: this package will \
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, \
plus `[net] offline = true`"
);
println!();
}
if no_verify || !is_interactive() {
if no_verify || !prompter.interactive() {
return;
}
@@ -585,7 +593,7 @@ pub async fn offer_verification(
} else {
"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,
Err(_) => return,
};
@@ -598,7 +606,7 @@ pub async fn offer_verification(
source: Some(tree.clone()),
options: crate::build::SourceBuildOptions::default(),
view: &*ui,
prompter: &crate::ui::prompt::TerminalPrompter,
prompter,
}) {
log::error!("Verification source build failed: {e}");
log::info!(
@@ -613,7 +621,7 @@ pub async fn offer_verification(
return;
}
let verify_deb = match prompt::confirm(
let verify_deb = match prompter.confirm(
"Verify with `pkh deb` now? (needs network + build deps)",
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
/// 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 {
let answer = prompt::select(LANGUAGE_LABEL, options, default)?;
let answer = prompter.select(LANGUAGE_LABEL, options, default)?;
match TemplateId::from_label(&answer) {
Some(id) => return Ok(id),
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
/// selector allows typing arbitrary text, which callers may need to reject).
fn select_from(
prompter: &dyn Prompter,
label: &str,
options: &[String],
default: &str,
accept: impl Fn(&str) -> bool,
) -> Result<String, Box<dyn Error>> {
loop {
let answer = prompt::select(label, options, default)?;
let answer = prompter.select(label, options, default)?;
if accept(&answer) {
return Ok(answer);
}
@@ -832,7 +845,7 @@ fn select_from(
/// upstream version like `1.0-2` carrying a Debian revision) is withheld —
/// the question is asked without a default instead of offering one that
/// 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() {
default
} 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 — must pass `validate`. `Err` carries the validation error so the
/// caller re-asks with it.
fn accept_answer(
answer: &str,
default: &str,
validate: &prompt::Validator,
) -> Result<String, String> {
fn accept_answer(answer: &str, default: &str, validate: &Validator) -> Result<String, String> {
let answer = if answer.is_empty() { default } else { answer };
validate(answer).map(|_| answer.to_string())
}
@@ -862,6 +871,7 @@ fn accept_answer(
/// validation error) — probe data can never bypass validation and only blow
/// up later in [`options::resolve`].
fn ask_text(
prompter: &dyn Prompter,
label: &str,
default: &str,
validate: impl Fn(&str) -> Result<(), String> + 'static,
@@ -883,7 +893,7 @@ fn ask_text(
}
};
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
// an empty one resolves to the default, pre-decided above.
let answer = if answer.is_empty() {
@@ -1545,7 +1555,7 @@ mod tests {
#[test]
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
// (regression: the wizard once rendered "> LicenseMIT").
for label in SELECT_LABELS {