new: validate wizard defaults like typed answers
ask_text accepted its default on Enter without running the question's validator, so a probed upstream version like 1.0-2 or v1.0 sailed through the whole questionnaire and crashed resolve() at the end, and an invalid git-derived maintainer default (e.g. 'Name <>') was accepted verbatim. ask_text now takes the validator and applies it to both typed answers and the offered default — a default that fails validation is withheld and an invalid answer re-asks — and all question call sites (incl. the maintainer loop) route through it.
This commit is contained in:
+171
-68
@@ -250,7 +250,10 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
let mut origin = packaged_dir.as_deref().and_then(GitOrigin::detect);
|
let mut origin = packaged_dir.as_deref().and_then(GitOrigin::detect);
|
||||||
|
|
||||||
// 4. Upstream version: probed project version, then the tag HEAD sits
|
// 4. Upstream version: probed project version, then the tag HEAD sits
|
||||||
// on, then `<lasttag>+git<YYYYMMDD>.<hash>`, then 0.1.0.
|
// on, then `<lasttag>+git<YYYYMMDD>.<hash>`, then 0.1.0. A raw probe
|
||||||
|
// that fails `validate_upstream_version` (e.g. `1.0-2` or `v1.0`) is
|
||||||
|
// not offered as the default — the same validator `resolve` applies
|
||||||
|
// decides, so Enter can never accept it and crash late.
|
||||||
if cli.upstream_version.is_none() {
|
if cli.upstream_version.is_none() {
|
||||||
let default = probe
|
let default = probe
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -259,9 +262,8 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
.or_else(|| origin.as_ref().and_then(GitOrigin::git_version))
|
.or_else(|| origin.as_ref().and_then(GitOrigin::git_version))
|
||||||
.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 answer = ask_text("Upstream version", &default, move |version: &str| {
|
let validate = move |version: &str| options::validate_upstream_version(version, revision);
|
||||||
options::validate_upstream_version(version, revision)
|
let answer = ask_text("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
|
||||||
@@ -334,31 +336,25 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.description.clone())
|
.and_then(|p| p.description.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
loop {
|
let validate = required_answer("the description");
|
||||||
let answer = ask_text(
|
let answer = ask_text("One-line description", &default, validate)?;
|
||||||
"One-line description",
|
cli.description = Some(answer);
|
||||||
&default,
|
|
||||||
required_answer("the description"),
|
|
||||||
)?;
|
|
||||||
if !answer.trim().is_empty() {
|
|
||||||
cli.description = Some(answer);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
log::warn!("A one-line description is required to scaffold a package");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Homepage.
|
// 7. Homepage (blank skips; a probed default must still be a valid URL).
|
||||||
if cli.homepage.is_none() {
|
if cli.homepage.is_none() {
|
||||||
let default = probe
|
let default = probe
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.homepage.clone())
|
.and_then(|p| p.homepage.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let answer = ask_text(
|
let validate = |url: &str| {
|
||||||
"Homepage (blank to skip)",
|
if url.is_empty() {
|
||||||
&default,
|
Ok(())
|
||||||
options::validate_homepage,
|
} else {
|
||||||
)?;
|
options::validate_homepage(url)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let answer = ask_text("Homepage (blank to skip)", &default, validate)?;
|
||||||
if !answer.is_empty() {
|
if !answer.is_empty() {
|
||||||
cli.homepage = Some(answer);
|
cli.homepage = Some(answer);
|
||||||
}
|
}
|
||||||
@@ -378,17 +374,8 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
options.contains(&answer.to_string())
|
options.contains(&answer.to_string())
|
||||||
})?;
|
})?;
|
||||||
if answer == LICENSE_OTHER {
|
if answer == LICENSE_OTHER {
|
||||||
let license = loop {
|
let validate = required_answer("the license identifier");
|
||||||
let candidate = ask_text(
|
let license = ask_text("License (SPDX identifier)", &custom_default, validate)?;
|
||||||
"License (SPDX identifier)",
|
|
||||||
&custom_default,
|
|
||||||
required_answer("the license identifier"),
|
|
||||||
)?;
|
|
||||||
if !candidate.trim().is_empty() {
|
|
||||||
break candidate;
|
|
||||||
}
|
|
||||||
log::warn!("A license identifier is required when picking the free-text entry");
|
|
||||||
};
|
|
||||||
cli.license = Some(license);
|
cli.license = Some(license);
|
||||||
} else {
|
} else {
|
||||||
cli.license = Some(answer);
|
cli.license = Some(answer);
|
||||||
@@ -396,43 +383,41 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 9. Command name (skipped for the empty template, where nothing is
|
// 9. Command name (skipped for the empty template, where nothing is
|
||||||
// installed).
|
// installed). The probed default must pass the required-answer check
|
||||||
|
// too, or the question is asked without one.
|
||||||
if cli.command.is_none() && template != TemplateId::Empty {
|
if cli.command.is_none() && template != TemplateId::Empty {
|
||||||
let default = probe
|
let default = probe
|
||||||
.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(
|
let validate = required_answer("the command name");
|
||||||
"Command name",
|
let command = ask_text("Command name", &default, validate)?;
|
||||||
&default,
|
cli.command = Some(command);
|
||||||
required_answer("the command name"),
|
|
||||||
)?;
|
|
||||||
cli.command = Some(if command.is_empty() {
|
|
||||||
cli.name.clone().unwrap_or_default()
|
|
||||||
} else {
|
|
||||||
command
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Maintainer, defaulting to the DEBEMAIL/git-config identity (an
|
// 10. Maintainer, defaulting to the DEBEMAIL/git-config identity. The
|
||||||
// empty answer re-asks).
|
// git-derived default goes through `parse_maintainer` like typed
|
||||||
|
// input: an empty git user.email yields a `Name <>` default that is
|
||||||
|
// ignored (the question is asked without one) instead of accepted
|
||||||
|
// verbatim and only failing late in `resolve`. An empty answer
|
||||||
|
// re-asks.
|
||||||
if cli.maintainer.is_none() {
|
if cli.maintainer.is_none() {
|
||||||
let default = crate::changelog::get_maintainer_info()
|
let candidate = crate::changelog::get_maintainer_info()
|
||||||
.map(|(name, email)| format!("{name} <{email}>"))
|
.map(|(name, email)| format!("{name} <{email}>"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let maintainer = loop {
|
let default = if options::parse_maintainer(&candidate).is_ok() {
|
||||||
let answer = ask_text("Maintainer", &default, |answer: &str| {
|
candidate
|
||||||
options::parse_maintainer(answer).map(|_| ())
|
} else {
|
||||||
})?;
|
String::new()
|
||||||
if !answer.is_empty() {
|
};
|
||||||
break answer;
|
if default.is_empty() {
|
||||||
}
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Could not determine a maintainer default (no DEBFULLNAME/\
|
"Could not determine a maintainer default (no DEBFULLNAME/\
|
||||||
DEBEMAIL and no git user config): answer as 'Name <email>'"
|
DEBEMAIL and no git user config): answer as 'Name <email>'"
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
cli.maintainer = Some(maintainer);
|
let validate = |answer: &str| options::parse_maintainer(answer).map(|_| ());
|
||||||
|
cli.maintainer = Some(ask_text("Maintainer", &default, validate)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. Target distribution.
|
// 11. Target distribution.
|
||||||
@@ -472,10 +457,11 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
|
|
||||||
// 13. Metapackage Depends (empty template only).
|
// 13. Metapackage Depends (empty template only).
|
||||||
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 answer = ask_text(
|
let answer = ask_text(
|
||||||
"Depends (metapackage, comma-separated, blank for an empty base)",
|
"Depends (metapackage, comma-separated, blank for an empty base)",
|
||||||
"",
|
"",
|
||||||
|answer: &str| options::validate_depends(answer).map(|_| ()),
|
validate,
|
||||||
)?;
|
)?;
|
||||||
if !answer.trim().is_empty() {
|
if !answer.trim().is_empty() {
|
||||||
cli.depends = vec![answer];
|
cli.depends = vec![answer];
|
||||||
@@ -799,17 +785,54 @@ fn select_from(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The default a question is offered with: a probed default must clear the
|
||||||
|
/// same `validate` bar as typed input, so an unusable probe (e.g. an
|
||||||
|
/// 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 {
|
||||||
|
if default.is_empty() || validate(default).is_ok() {
|
||||||
|
default
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One round of [`ask_text`]: an empty answer takes the (pre-validated)
|
||||||
|
/// `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> {
|
||||||
|
let answer = if answer.is_empty() { default } else { answer };
|
||||||
|
validate(answer).map(|_| answer.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// One free-text question implementing the spec's "Enter accepts the
|
/// One free-text question implementing the spec's "Enter accepts the
|
||||||
/// default": an empty answer falls back to `default` (`Esc` keeps its
|
/// default": an empty answer falls back to the default (`Esc` keeps its
|
||||||
/// prompt-level meaning of restoring the default). `validate` only ever
|
/// prompt-level meaning of restoring it). Both the typed answer and the
|
||||||
/// sees non-empty answers — the empty one is accepted by the prompt loop so
|
/// default go through `validate`: a probed default that fails validation is
|
||||||
/// it can take the default path; callers that require an answer re-check
|
/// never offered ([`offered_default`]), and an empty answer without a valid
|
||||||
/// the result.
|
/// default is treated like any other invalid answer (re-ask with the
|
||||||
|
/// validation error) — probe data can never bypass validation and only blow
|
||||||
|
/// up later in [`options::resolve`].
|
||||||
fn ask_text(
|
fn ask_text(
|
||||||
label: &str,
|
label: &str,
|
||||||
default: &str,
|
default: &str,
|
||||||
validate: impl Fn(&str) -> Result<(), String> + 'static,
|
validate: impl Fn(&str) -> Result<(), String> + 'static,
|
||||||
) -> Result<String, Box<dyn Error>> {
|
) -> Result<String, Box<dyn Error>> {
|
||||||
|
// The offered default must clear the same bar as typed input: an
|
||||||
|
// unusable probe is withheld and the question is asked without one.
|
||||||
|
let default = offered_default(default, &validate);
|
||||||
|
// Enter/Esc (and the non-TTY fallback) return the default without
|
||||||
|
// validation, so pre-decide what an empty answer yields: the default
|
||||||
|
// itself (it passed the bar above), or the rejection it shares with any
|
||||||
|
// invalid answer when no valid default exists. Computed here because
|
||||||
|
// `validate` moves into the prompt wrapper below.
|
||||||
|
let empty_answer = accept_answer("", default, &validate);
|
||||||
let accept_empty = move |answer: &str| {
|
let accept_empty = move |answer: &str| {
|
||||||
if answer.is_empty() {
|
if answer.is_empty() {
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -817,12 +840,20 @@ fn ask_text(
|
|||||||
validate(answer)
|
validate(answer)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let answer = prompt::text(label, default, Some(&accept_empty))?;
|
loop {
|
||||||
Ok(if answer.is_empty() {
|
let answer = prompt::text(label, default, Some(&accept_empty))?;
|
||||||
default.to_string()
|
// Typed non-empty answers were already validated by the prompt; only
|
||||||
} else {
|
// an empty one resolves to the default, pre-decided above.
|
||||||
answer
|
let answer = if answer.is_empty() {
|
||||||
})
|
empty_answer.clone()
|
||||||
|
} else {
|
||||||
|
Ok(answer)
|
||||||
|
};
|
||||||
|
match answer {
|
||||||
|
Ok(answer) => return Ok(answer),
|
||||||
|
Err(error) => log::warn!("{error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A validator requiring a non-empty answer.
|
/// A validator requiring a non-empty answer.
|
||||||
@@ -1404,6 +1435,78 @@ mod tests {
|
|||||||
assert!(required_answer("x")("ok").is_ok());
|
assert!(required_answer("x")("ok").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A probed upstream-version default must clear the same
|
||||||
|
/// [`options::validate_upstream_version`] bar that `resolve` applies: a
|
||||||
|
/// raw probe carrying a Debian revision or a `v` prefix fails it, so the
|
||||||
|
/// wizard withholds it instead of offering it for blind Enter-acceptance
|
||||||
|
/// and crashing late in `resolve` (regression).
|
||||||
|
#[test]
|
||||||
|
fn probed_version_defaults_fail_upstream_validation() {
|
||||||
|
assert!(options::validate_upstream_version("1.0-2", 1).is_err());
|
||||||
|
assert!(options::validate_upstream_version("v1.0", 1).is_err());
|
||||||
|
assert!(options::validate_upstream_version("1.0.0", 1).is_ok());
|
||||||
|
|
||||||
|
let validate = |version: &str| options::validate_upstream_version(version, 1);
|
||||||
|
// Invalid probes are not offered as the default...
|
||||||
|
assert_eq!(offered_default("1.0-2", &validate), "");
|
||||||
|
assert_eq!(offered_default("v1.0", &validate), "");
|
||||||
|
// ...clean probes keep theirs, and no default stays none.
|
||||||
|
assert_eq!(offered_default("1.0.0", &validate), "1.0.0");
|
||||||
|
assert_eq!(offered_default("", &validate), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`accept_answer`] routes typed answers and Enter-taken defaults
|
||||||
|
/// through the same validation: valid typed input accepted verbatim,
|
||||||
|
/// invalid typed input rejected (re-ask), an empty answer taking a valid
|
||||||
|
/// default, and an empty answer with an invalid or missing default
|
||||||
|
/// rejected like any other invalid answer.
|
||||||
|
#[test]
|
||||||
|
fn accept_answer_validates_typed_answers_and_defaults() {
|
||||||
|
let validate = |answer: &str| {
|
||||||
|
if answer == "ok" {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("not ok: {answer}"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(accept_answer("ok", "ok", &validate), Ok("ok".to_string()));
|
||||||
|
assert!(accept_answer("bad", "ok", &validate).is_err());
|
||||||
|
|
||||||
|
// An empty answer takes the default, which must itself validate: an
|
||||||
|
// invalid (or missing) default is rejected, not taken.
|
||||||
|
assert_eq!(accept_answer("", "ok", &validate), Ok("ok".to_string()));
|
||||||
|
assert!(accept_answer("", "bad", &validate).is_err());
|
||||||
|
assert!(accept_answer("", "", &validate).is_err());
|
||||||
|
|
||||||
|
// The upstream-version scenario end to end: Enter on an invalid
|
||||||
|
// probed default (offered as none) fails instead of taking it
|
||||||
|
// verbatim, while a valid default is taken.
|
||||||
|
let version = |v: &str| options::validate_upstream_version(v, 1);
|
||||||
|
assert!(accept_answer("", "1.0-2", &version).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
accept_answer("", "1.0.0", &version),
|
||||||
|
Ok("1.0.0".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The git-derived maintainer default goes through
|
||||||
|
/// [`options::parse_maintainer`] before it is offered: an empty git
|
||||||
|
/// email yields a `Name <>` default that parses to an error and is
|
||||||
|
/// ignored, instead of being accepted verbatim and only failing late in
|
||||||
|
/// `resolve` (regression).
|
||||||
|
#[test]
|
||||||
|
fn maintainer_default_validated_by_parse_maintainer() {
|
||||||
|
assert!(
|
||||||
|
options::parse_maintainer("Jane Doe <jane@example.com>").is_ok(),
|
||||||
|
"a well-formed identity is offered as the default"
|
||||||
|
);
|
||||||
|
// Empty git user.email (or DEBEMAIL): `Name <>` must not parse.
|
||||||
|
assert!(options::parse_maintainer("Jane Doe <>").is_err());
|
||||||
|
// An empty git user.name: `<>` (or `<email>`) must not parse either.
|
||||||
|
assert!(options::parse_maintainer(" <jane@example.com>").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn select_labels_carry_their_own_separator() {
|
fn select_labels_carry_their_own_separator() {
|
||||||
// prompt::select renders `> <label><answer>` verbatim; a label
|
// prompt::select renders `> <label><answer>` verbatim; a label
|
||||||
|
|||||||
Reference in New Issue
Block a user