new: type the scaffold validator errors

The leaf validators of the wizard (source name, command, upstream
version, maintainer, homepage, Depends, orig path, template id, file
collisions) now return a shared options::ValidationError carrying the
structured pieces (the rejected string, the wrapped DebianVersion or
Deps parse failure) instead of pre-formatted Strings; the wizard
resolve flow keeps its aggregate String errors and renders at the
boundary, and the Prompter validator closures adapt with to_string()
since the port's Validator deliberately exchanges re-ask explanations
as plain strings. Display texts are unchanged, as are the tests that
assert on them.
This commit is contained in:
2026-10-03 02:17:22 +02:00
parent 82a15c7426
commit a59e99b107
2 changed files with 193 additions and 88 deletions
+167 -69
View File
@@ -63,22 +63,12 @@ impl TemplateId {
} }
/// Parse a CLI identifier, accepting exactly the registered spellings. /// Parse a CLI identifier, accepting exactly the registered spellings.
pub fn parse(s: &str) -> Result<TemplateId, String> { pub fn parse(s: &str) -> Result<TemplateId, ValidationError> {
Self::all() Self::all()
.iter() .iter()
.copied() .copied()
.find(|id| id.as_str() == s) .find(|id| id.as_str() == s)
.ok_or_else(|| { .ok_or_else(|| ValidationError::UnknownTemplate { id: s.to_string() })
format!(
"Unknown language/template '{}'. Supported values are: {}.",
s,
Self::all()
.iter()
.map(|id| id.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})
} }
/// Human-readable menu label of this template, as offered by the wizard /// Human-readable menu label of this template, as offered by the wizard
@@ -407,21 +397,132 @@ pub struct NewCli {
pub defaults: bool, pub defaults: bool,
} }
/// Failure of one of the scaffold option validators ([`validate_source_name`],
/// [`validate_command`], [`validate_upstream_version`], [`parse_maintainer`],
/// [`validate_homepage`], [`validate_depends`], [`validate_orig_path`],
/// [`check_file_collisions`], [`TemplateId::parse`]): what the wizard re-asks
/// about, carrying the pieces callers may want to match on.
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
/// The source package name is shorter than dpkg's 2-character minimum.
#[error("'{name}' is too short: package names need at least 2 characters")]
SourceNameTooShort {
/// The rejected name.
name: String,
},
/// The source package name is outside dpkg's charset or shape.
#[error(
"'{}' is not a valid package name: names must be lowercase, start with a letter or digit and may only contain [a-z0-9+.-]",
name
)]
InvalidSourceName {
/// The rejected name.
name: String,
},
/// No installed command was given.
#[error("the command name must not be empty")]
EmptyCommand,
/// The command name is not a lowercase identifier.
#[error(
"'{}' is not a valid command name: commands must be a lowercase identifier (letters, digits, + - . _), e.g. 'mytool'",
command
)]
InvalidCommand {
/// The rejected command name.
command: String,
},
/// Upstream versions should start with a digit (dpkg recommendation,
/// enforced here).
#[error("upstream versions should start with a digit")]
UpstreamNotDigit,
/// `-` is the Debian revision separator.
#[error(
"upstream versions must not contain '-': the Debian revision is appended automatically"
)]
UpstreamContainsDash,
/// The upstream version composed with the Debian revision does not
/// parse as a Debian version.
#[error("'{version}' is not a valid Debian version: {source}")]
InvalidVersion {
/// The composed `upstream-revision` string.
version: String,
/// The version parse failure.
source: crate::debian::version::VersionParseError,
},
/// The maintainer is not of the form `Name <email>`.
#[error("'{s}' is not a valid maintainer: expected the form 'Name <email>'")]
MaintainerShape {
/// The rejected maintainer string.
s: String,
},
/// The maintainer name (before the angle brackets) is empty.
#[error("'{s}': the maintainer name must not be empty")]
MaintainerNameEmpty {
/// The rejected maintainer string.
s: String,
},
/// The maintainer email does not carry exactly one `@`.
#[error("'{s}': the maintainer email must contain exactly one '@'")]
MaintainerEmailAt {
/// The rejected maintainer string.
s: String,
},
/// The maintainer email carries whitespace.
#[error("'{s}': the maintainer email must not contain whitespace")]
MaintainerEmailWhitespace {
/// The rejected maintainer string.
s: String,
},
/// The homepage is not an http(s) URL.
#[error("'{url}' is not a valid homepage: expected an http:// or https:// URL")]
InvalidHomepage {
/// The rejected URL.
url: String,
},
/// The Depends list does not parse as package relations.
#[error("Invalid Depends list '{list}': {source}")]
InvalidDepends {
/// The rejected list.
list: String,
/// The relation parse failure.
source: crate::debian::deps::ParseError,
},
/// The orig tarball is neither an http(s) URL nor an existing file.
#[error(
"'{path}' is not an http(s) URL and not an existing file: --orig-path must point at a tarball to repack"
)]
InvalidOrigPath {
/// The rejected path or URL.
path: String,
},
/// The language/template id is not one of the registered spellings.
#[error("Unknown language/template '{}'. Supported values are: {}.", id, TemplateId::all().iter().map(|id| id.as_str()).collect::<Vec<_>>().join(", "))]
UnknownTemplate {
/// The unknown id.
id: String,
},
/// A template generated the same file twice.
#[error("internal error: file '{path}' was generated more than once")]
GeneratedTwice {
/// The duplicated path.
path: String,
},
}
/// Validate a Debian source package name: `^[a-z0-9][a-z0-9+.\-]+$` with a /// Validate a Debian source package name: `^[a-z0-9][a-z0-9+.\-]+$` with a
/// minimum length of 2 (dpkg rules). /// minimum length of 2 (dpkg rules).
pub fn validate_source_name(name: &str) -> Result<(), String> { pub fn validate_source_name(name: &str) -> Result<(), ValidationError> {
static NAME_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new(); static NAME_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let regex = NAME_REGEX.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9+.\-]+$").unwrap()); let regex = NAME_REGEX.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9+.\-]+$").unwrap());
if name.len() < 2 { if name.len() < 2 {
return Err(format!( return Err(ValidationError::SourceNameTooShort {
"'{name}' is too short: package names need at least 2 characters" name: name.to_string(),
)); });
} }
if !regex.is_match(name) { if !regex.is_match(name) {
return Err(format!( return Err(ValidationError::InvalidSourceName {
"'{name}' is not a valid package name: names must be lowercase, \ name: name.to_string(),
start with a letter or digit and may only contain [a-z0-9+.-]" });
));
} }
Ok(()) Ok(())
} }
@@ -473,17 +574,16 @@ pub fn sanitize_name(input: &str) -> Option<String> {
/// `meson.build` and the `[project.scripts]` table, so uppercase letters, /// `meson.build` and the `[project.scripts]` table, so uppercase letters,
/// whitespace, quotes and shell metacharacters are rejected here instead of /// whitespace, quotes and shell metacharacters are rejected here instead of
/// generating broken install lines and build files. /// generating broken install lines and build files.
pub fn validate_command(command: &str) -> Result<(), String> { pub fn validate_command(command: &str) -> Result<(), ValidationError> {
static COMMAND_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new(); static COMMAND_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let regex = COMMAND_REGEX.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9+.\-_]*$").unwrap()); let regex = COMMAND_REGEX.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9+.\-_]*$").unwrap());
if command.is_empty() { if command.is_empty() {
return Err("the command name must not be empty".to_string()); return Err(ValidationError::EmptyCommand);
} }
if !regex.is_match(command) { if !regex.is_match(command) {
return Err(format!( return Err(ValidationError::InvalidCommand {
"'{command}' is not a valid command name: commands must be a \ command: command.to_string(),
lowercase identifier (letters, digits, + - . _), e.g. 'mytool'" });
));
} }
Ok(()) Ok(())
} }
@@ -492,65 +592,60 @@ pub fn validate_command(command: &str) -> Result<(), String> {
/// recommendation, enforced here) and survive [`DebianVersion::parse`] once /// recommendation, enforced here) and survive [`DebianVersion::parse`] once
/// composed with the Debian revision. It must not contain `-` (the revision /// composed with the Debian revision. It must not contain `-` (the revision
/// separator). /// separator).
pub fn validate_upstream_version(upstream: &str, revision: u32) -> Result<(), String> { pub fn validate_upstream_version(upstream: &str, revision: u32) -> Result<(), ValidationError> {
if !upstream.starts_with(|c: char| c.is_ascii_digit()) { if !upstream.starts_with(|c: char| c.is_ascii_digit()) {
return Err("upstream versions should start with a digit".to_string()); return Err(ValidationError::UpstreamNotDigit);
} }
if upstream.contains('-') { if upstream.contains('-') {
return Err( return Err(ValidationError::UpstreamContainsDash);
"upstream versions must not contain '-': the Debian revision is \
appended automatically"
.to_string(),
);
} }
let composed = format!("{}-{}", upstream, revision); let composed = format!("{}-{}", upstream, revision);
DebianVersion::parse(&composed) DebianVersion::parse(&composed)
.map(|_| ()) .map(|_| ())
.map_err(|e| format!("'{composed}' is not a valid Debian version: {e}")) .map_err(|source| ValidationError::InvalidVersion {
version: composed.clone(),
source,
})
} }
/// Parse a `Name <email>` maintainer string: the name must be non-empty and /// Parse a `Name <email>` maintainer string: the name must be non-empty and
/// the email must contain exactly one `@` with no whitespace inside the /// the email must contain exactly one `@` with no whitespace inside the
/// angle brackets. /// angle brackets.
pub fn parse_maintainer(s: &str) -> Result<(String, String), String> { pub fn parse_maintainer(s: &str) -> Result<(String, String), ValidationError> {
static MAINTAINER_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new(); static MAINTAINER_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let regex = MAINTAINER_REGEX.get_or_init(|| Regex::new(r"^(.+?)\s*<([^<>]*)>\s*$").unwrap()); let regex = MAINTAINER_REGEX.get_or_init(|| Regex::new(r"^(.+?)\s*<([^<>]*)>\s*$").unwrap());
let caps = regex.captures(s.trim()).ok_or_else(|| { let caps = regex
format!("'{s}' is not a valid maintainer: expected the form 'Name <email>'") .captures(s.trim())
})?; .ok_or_else(|| ValidationError::MaintainerShape { s: s.to_string() })?;
let name = caps[1].trim().to_string(); let name = caps[1].trim().to_string();
let email = caps[2].trim().to_string(); let email = caps[2].trim().to_string();
if name.is_empty() { if name.is_empty() {
return Err(format!("'{s}': the maintainer name must not be empty")); return Err(ValidationError::MaintainerNameEmpty { s: s.to_string() });
} }
if !email.contains('@') || email.matches('@').count() > 1 { if !email.contains('@') || email.matches('@').count() > 1 {
return Err(format!( return Err(ValidationError::MaintainerEmailAt { s: s.to_string() });
"'{s}': the maintainer email must contain exactly one '@'"
));
} }
if email.chars().any(char::is_whitespace) { if email.chars().any(char::is_whitespace) {
return Err(format!( return Err(ValidationError::MaintainerEmailWhitespace { s: s.to_string() });
"'{s}': the maintainer email must not contain whitespace"
));
} }
Ok((name, email)) Ok((name, email))
} }
/// Validate a homepage URL: it must carry an `http(s)://` prefix. /// Validate a homepage URL: it must carry an `http(s)://` prefix.
pub fn validate_homepage(url: &str) -> Result<(), String> { pub fn validate_homepage(url: &str) -> Result<(), ValidationError> {
if url.starts_with("http://") || url.starts_with("https://") { if url.starts_with("http://") || url.starts_with("https://") {
Ok(()) Ok(())
} else { } else {
Err(format!( Err(ValidationError::InvalidHomepage {
"'{url}' is not a valid homepage: expected an http:// or https:// URL" url: url.to_string(),
)) })
} }
} }
/// Validate a comma-separated runtime Depends list (the metapackage flavor) /// Validate a comma-separated runtime Depends list (the metapackage flavor)
/// by parsing it with [`Deps::parse`] and re-rendering every clause /// by parsing it with [`Deps::parse`] and re-rendering every clause
/// canonically. /// canonically.
pub fn validate_depends(list: &str) -> Result<Vec<String>, String> { pub fn validate_depends(list: &str) -> Result<Vec<String>, ValidationError> {
let opts = ParseOpts { let opts = ParseOpts {
host_arch: crate::get_current_arch(), host_arch: crate::get_current_arch(),
build_arch: crate::get_current_arch(), build_arch: crate::get_current_arch(),
@@ -559,8 +654,10 @@ pub fn validate_depends(list: &str) -> Result<Vec<String>, String> {
union: false, union: false,
build_dep: false, build_dep: false,
}; };
let deps = let deps = Deps::parse(list, &opts).map_err(|source| ValidationError::InvalidDepends {
Deps::parse(list, &opts).map_err(|e| format!("Invalid Depends list '{list}': {e}"))?; list: list.to_string(),
source,
})?;
Ok(deps Ok(deps
.clauses() .clauses()
.map(|clause| { .map(|clause| {
@@ -620,7 +717,7 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
// (nothing to detect for a fresh skeleton), else --defaults picks the // (nothing to detect for a fresh skeleton), else --defaults picks the
// empty template. // empty template.
let template = match &cli.lang { let template = match &cli.lang {
Some(lang) => Some(TemplateId::parse(lang)?), Some(lang) => Some(TemplateId::parse(lang).map_err(|e| e.to_string())?),
None => match &source_dir { None => match &source_dir {
SourceDir::Skeleton => { SourceDir::Skeleton => {
if cli.defaults { if cli.defaults {
@@ -706,7 +803,7 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
} }
// Everything below has a default and is validated as it is resolved. // Everything below has a default and is validated as it is resolved.
validate_source_name(&name)?; validate_source_name(&name).map_err(|e| e.to_string())?;
// Source format: explicit flags win (--native / --quilt, mutually // Source format: explicit flags win (--native / --quilt, mutually
// exclusive), otherwise the mode decides — a fresh, self-authored // exclusive), otherwise the mode decides — a fresh, self-authored
@@ -769,7 +866,7 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
let homepage = match &cli.homepage { let homepage = match &cli.homepage {
Some(h) => { Some(h) => {
validate_homepage(h)?; validate_homepage(h).map_err(|e| e.to_string())?;
Some(h.clone()) Some(h.clone())
} }
None => None, None => None,
@@ -780,10 +877,10 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
let command = cli.command.unwrap_or_else(|| name.clone()); let command = cli.command.unwrap_or_else(|| name.clone());
// The default is the already-validated package name (a subset of the // The default is the already-validated package name (a subset of the
// command charset), so only an explicit --command/wizard answer can fail. // command charset), so only an explicit --command/wizard answer can fail.
validate_command(&command)?; validate_command(&command).map_err(|e| e.to_string())?;
let maintainer = match &cli.maintainer { let maintainer = match &cli.maintainer {
Some(m) => parse_maintainer(m)?, Some(m) => parse_maintainer(m).map_err(|e| e.to_string())?,
None => crate::changelog::get_maintainer_info().map_err(|e| { None => crate::changelog::get_maintainer_info().map_err(|e| {
format!( format!(
"Could not determine the maintainer ({e}). \ "Could not determine the maintainer ({e}). \
@@ -815,7 +912,7 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
let depends = if joined.trim().is_empty() { let depends = if joined.trim().is_empty() {
Vec::new() Vec::new()
} else { } else {
validate_depends(&joined)? validate_depends(&joined).map_err(|e| e.to_string())?
}; };
Ok(NewOptions { Ok(NewOptions {
@@ -874,7 +971,7 @@ fn resolve_orig_origin(
let source = cli.orig_path.clone().ok_or_else(|| { let source = cli.orig_path.clone().ok_or_else(|| {
"--orig-from path requires --orig-path <tarball file or URL>".to_string() "--orig-from path requires --orig-path <tarball file or URL>".to_string()
})?; })?;
validate_orig_path(&source)?; validate_orig_path(&source).map_err(|e| e.to_string())?;
OrigOrigin::Provided { source } OrigOrigin::Provided { source }
} }
Some("git") => { Some("git") => {
@@ -935,27 +1032,26 @@ fn resolve_orig_origin(
/// Validate a `--orig-path` value (and the wizard's tarball answer): either /// Validate a `--orig-path` value (and the wizard's tarball answer): either
/// an http(s) URL (downloaded at scaffold time) or an existing local /// an http(s) URL (downloaded at scaffold time) or an existing local
/// tarball. /// tarball.
pub fn validate_orig_path(source: &str) -> Result<(), String> { pub fn validate_orig_path(source: &str) -> Result<(), ValidationError> {
if source.starts_with("http://") || source.starts_with("https://") { if source.starts_with("http://") || source.starts_with("https://") {
return Ok(()); return Ok(());
} }
if Path::new(source).is_file() { if Path::new(source).is_file() {
return Ok(()); return Ok(());
} }
Err(format!( Err(ValidationError::InvalidOrigPath {
"'{source}' is not an http(s) URL and not an existing file: \ path: source.to_string(),
--orig-path must point at a tarball to repack" })
))
} }
/// Set of names no template may produce twice (collision check). /// Set of names no template may produce twice (collision check).
pub(crate) fn check_file_collisions(paths: &[String]) -> Result<(), String> { pub(crate) fn check_file_collisions(paths: &[String]) -> Result<(), ValidationError> {
let mut seen: HashSet<&String> = HashSet::new(); let mut seen: HashSet<&String> = HashSet::new();
for path in paths { for path in paths {
if !seen.insert(path) { if !seen.insert(path) {
return Err(format!( return Err(ValidationError::GeneratedTwice {
"internal error: file '{path}' was generated more than once" path: path.to_string(),
)); });
} }
} }
Ok(()) Ok(())
@@ -1222,7 +1318,9 @@ mod tests {
#[test] #[test]
fn collision_detection() { fn collision_detection() {
assert!(check_file_collisions(&["a".into(), "b".into()]).is_ok()); assert!(check_file_collisions(&["a".into(), "b".into()]).is_ok());
let err = check_file_collisions(&["a".into(), "b".into(), "a".into()]).unwrap_err(); let err = check_file_collisions(&["a".into(), "b".into(), "a".into()])
.unwrap_err()
.to_string();
assert!(err.contains('a')); assert!(err.contains('a'));
} }
+26 -19
View File
@@ -96,12 +96,9 @@ async fn run_wizard(
// 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( let answer = ask_text(prompter, "Package name", &default, |name: &str| {
prompter, options::validate_source_name(name).map_err(|e| e.to_string())
"Package name", })?;
&default,
options::validate_source_name,
)?;
cli.name = Some(answer); cli.name = Some(answer);
} }
@@ -265,7 +262,9 @@ async fn run_wizard(
.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 validate = move |version: &str| options::validate_upstream_version(version, revision); let validate = move |version: &str| {
options::validate_upstream_version(version, revision).map_err(|e| e.to_string())
};
let answer = ask_text(prompter, "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());
@@ -321,7 +320,8 @@ async fn run_wizard(
.expect("answer comes from the choice list"); .expect("answer comes from the choice list");
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).map_err(|e| e.to_string());
let path = prompter.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);
} }
@@ -354,7 +354,7 @@ async fn run_wizard(
if url.is_empty() { if url.is_empty() {
Ok(()) Ok(())
} else { } else {
options::validate_homepage(url) options::validate_homepage(url).map_err(|e| e.to_string())
} }
}; };
let answer = ask_text(prompter, "Homepage (blank to skip)", &default, validate)?; let answer = ask_text(prompter, "Homepage (blank to skip)", &default, validate)?;
@@ -400,12 +400,9 @@ async fn run_wizard(
.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 command = ask_text(prompter, "Command name", &default, |command: &str| {
prompter, options::validate_command(command).map_err(|e| e.to_string())
"Command name", })?;
&default,
options::validate_command,
)?;
cli.command = Some(command); cli.command = Some(command);
} }
@@ -430,7 +427,11 @@ async fn run_wizard(
DEBEMAIL and no git user config): answer as 'Name <email>'" DEBEMAIL and no git user config): answer as 'Name <email>'"
); );
} }
let validate = |answer: &str| options::parse_maintainer(answer).map(|_| ()); let validate = |answer: &str| {
options::parse_maintainer(answer)
.map(|_| ())
.map_err(|e| e.to_string())
};
cli.maintainer = Some(ask_text(prompter, "Maintainer", &default, validate)?); cli.maintainer = Some(ask_text(prompter, "Maintainer", &default, validate)?);
} }
@@ -480,7 +481,11 @@ async fn run_wizard(
// 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 validate = |answer: &str| {
options::validate_depends(answer)
.map(|_| ())
.map_err(|e| e.to_string())
};
let answer = ask_text( let answer = ask_text(
prompter, prompter,
"Depends (metapackage, comma-separated, blank for an empty base)", "Depends (metapackage, comma-separated, blank for an empty base)",
@@ -1496,7 +1501,9 @@ mod tests {
assert!(options::validate_upstream_version("v1.0", 1).is_err()); assert!(options::validate_upstream_version("v1.0", 1).is_err());
assert!(options::validate_upstream_version("1.0.0", 1).is_ok()); assert!(options::validate_upstream_version("1.0.0", 1).is_ok());
let validate = |version: &str| options::validate_upstream_version(version, 1); let validate = |version: &str| {
options::validate_upstream_version(version, 1).map_err(|e| e.to_string())
};
// Invalid probes are not offered as the default... // Invalid probes are not offered as the default...
assert_eq!(offered_default("1.0-2", &validate), ""); assert_eq!(offered_default("1.0-2", &validate), "");
assert_eq!(offered_default("v1.0", &validate), ""); assert_eq!(offered_default("v1.0", &validate), "");
@@ -1532,7 +1539,7 @@ mod tests {
// The upstream-version scenario end to end: Enter on an invalid // The upstream-version scenario end to end: Enter on an invalid
// probed default (offered as none) fails instead of taking it // probed default (offered as none) fails instead of taking it
// verbatim, while a valid default is taken. // verbatim, while a valid default is taken.
let version = |v: &str| options::validate_upstream_version(v, 1); let version = |v: &str| options::validate_upstream_version(v, 1).map_err(|e| e.to_string());
assert!(accept_answer("", "1.0-2", &version).is_err()); assert!(accept_answer("", "1.0-2", &version).is_err());
assert_eq!( assert_eq!(
accept_answer("", "1.0.0", &version), accept_answer("", "1.0.0", &version),