Files
pkh/src/new/questions.rs
T
vhaudiquet 6caedce61a report: reproduce the pre-refactor CLI output through the ports
Instead of carrying raw UI in core, the ports now represent everything
the CLI used to do inline:

- Prompter::present shows context outside of a question (the wizard
  summary screen, the vendoring notice spacing); TerminalPrompter
  prints it on stdout exactly like the println!s it replaces, server
  embeds forward it as a display event.
- generate_entry returns the generated entry (package, versions,
  series, path) instead of printing; the CLI renders the same lines.
- BuildTarget carries a flow-composed display line and a tee_log flag:
  the terminal adapter renders it verbatim ("Building source package
  ...", "Building ... for series/arch", "Uploading ... to ...") and
  uploads open no build log.
- The unmet build-dependency diagnostics are rendered by the CLI from
  the typed error, in the original order (details, then summary).
- --verbose constructs no live view at all (an idle widget used to
  linger), and the re-vendor offer only logs when it is actually
  asked, so headless runs print the error exactly once.
2026-09-19 00:52:31 +02:00

1661 lines
65 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The `pkh new` interactive wizard.
//!
//! [`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. Headless (or with `--defaults`) it goes
//! straight through [`options::resolve`], whose error lists every missing
//! answer.
//!
//! After the summary screen is confirmed, the wizard offers the two
//! verification builds of the spec ([`offer_verification`]); a failed
//! verification never undoes the scaffold.
//!
//! 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::path::PathBuf;
use indicatif::MultiProgress;
use crate::new::detect::{self, Detection};
use crate::new::git;
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::report::{Prompter, Validator};
/// Answer of the "where is the source code?" question: fresh skeleton.
const SOURCE_SKELETON: &str = "Create a new project skeleton here";
/// Answer of the "where is the source code?" question: this directory.
const SOURCE_HERE: &str = "Package the sources in this directory";
/// Answer of the "where is the source code?" question: another directory.
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. 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? ";
const SOURCE_LABEL: &str = "Where is the source code? ";
const LICENSE_LABEL: &str = "License: ";
const DIST_LABEL: &str = "Target distribution: ";
const SERIES_LABEL: &str = "Target series: ";
const ORIG_LABEL: &str = "Where should the orig tarball come from? ";
/// All select labels, so the separator test can check them in one place.
#[cfg(test)]
const SELECT_LABELS: [&str; 6] = [
LANGUAGE_LABEL,
SOURCE_LABEL,
LICENSE_LABEL,
DIST_LABEL,
SERIES_LABEL,
ORIG_LABEL,
];
/// 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, prompter).await
}
/// The wizard question flow (spec "Proposed UX"), in order:
/// package name, language/build system, source location, upstream version
/// (with the checkout-tag offer when the version names an existing tag),
/// the orig-tarball origin (quilt + existing project only), Debian
/// revision, one-line description, homepage, license, command name,
/// maintainer, target distribution, target series, metapackage Depends
/// (`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,
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);
// Whether the flags imply a fresh skeleton (name given, no --source).
let implied_skeleton = cli.source.is_none() && cli.name.is_some();
// Whether the language question is skipped by a confident detection
// (only when packaging the detected directory, never for a skeleton).
let detection_decides = matches!(detection, Detection::Single(_)) && !implied_skeleton;
// 1. Package name: the detected project name, else the sanitized
// basename of the current directory.
if cli.name.is_none() {
let default = default_package_name(&cwd, probe.as_ref());
let answer = ask_text(
prompter,
"Package name",
&default,
options::validate_source_name,
)?;
cli.name = Some(answer);
}
// 2. Language / build system. An explicit `--lang` wins over any
// detection (flag > detected/probe > default): it is never
// overwritten and the question is never re-asked — the detection is
// only logged for information.
match language_choice(cli.lang.as_deref(), &detection, detection_decides) {
LanguageChoice::Flag => match &detection {
Detection::Single(id) => log::info!(
"Detected: {} project in {}; --lang takes precedence",
id.display_name(),
detect_dir.display()
),
Detection::Ambiguous(candidates) => log::info!(
"Several build systems found in {} ({}); --lang takes \
precedence",
detect_dir.display(),
candidates
.iter()
.map(|id| id.as_str())
.collect::<Vec<_>>()
.join(", ")
),
Detection::Empty => {}
},
LanguageChoice::Detected(id) => {
log::info!(
"Detected: {} project in {}",
id.display_name(),
detect_dir.display()
);
cli.lang = Some(id.as_str().to_string());
}
LanguageChoice::Ask(preselected) => {
let menu = language_menu(&[]);
let default = preselected
.unwrap_or(TemplateId::EMPTY)
.display_name()
.to_string();
let id = select_template(prompter, &menu, &default)?;
cli.lang = Some(id.as_str().to_string());
}
LanguageChoice::Ambiguous(candidates) => {
log::info!(
"Several build systems found in {} ({}): candidates listed \
first, the highest-precedence one preselected",
detect_dir.display(),
candidates
.iter()
.map(|id| id.as_str())
.collect::<Vec<_>>()
.join(", ")
);
let menu = language_menu(&candidates);
let id = select_template(prompter, &menu, &menu[0])?;
cli.lang = Some(id.as_str().to_string());
}
}
let template = TemplateId::parse(cli.lang.as_deref().unwrap_or_default())?;
// The rust toolchain pin of the packaged project does not travel into
// the chroot build: surface it now so a too-old pin is not a surprise
// when `pkh deb` compiles with the distribution's rustc.
let toolchain_pin = if template == TemplateId::RUST {
probe.as_ref().and_then(|p| p.toolchain_pin.clone())
} else {
None
};
if let Some(pin) = &toolchain_pin {
log::info!(
"Project pins rust {pin} via rust-toolchain.toml; the chroot \
build uses the distribution's rustc and ignores the pin — \
adjust or remove it if the code needs newer compiler features"
);
}
// 3. Source location. Skipped (with the inline notice) when a confident
// detection already decided to package the current directory; a
// --source flag skips it too.
if cli.source.is_none() && !detection_decides {
let options = vec![
SOURCE_SKELETON.to_string(),
SOURCE_HERE.to_string(),
SOURCE_PATH.to_string(),
];
let default = if implied_skeleton {
SOURCE_SKELETON
} else {
SOURCE_HERE
};
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 = prompter.text("Source directory", "", Some(&validator))?;
cli.source = Some(PathBuf::from(path));
}
// SOURCE_SKELETON: cli.source stays unset (the name decides).
} else if cli.source.is_none() {
cli.source = Some(cwd.clone());
}
// The source format this run will produce: explicit flags win, then the
// mode (skeleton → native, existing project → quilt). The wizard
// surfaces it in the summary; `options::resolve` re-derives it the same
// way for the non-interactive path.
let source_format = match (cli.native, cli.quilt) {
(true, true) => {
return Err("--native and --quilt are mutually exclusive".into());
}
(true, false) => SourceFormat::Native,
(false, true) => SourceFormat::Quilt,
(false, false) => {
if cli.source.is_none() {
SourceFormat::Native
} else {
SourceFormat::Quilt
}
}
};
// Git origin of the packaged directory: drives the version default, the
// checkout-tag offer and the orig-tarball question. Purely local.
let packaged_dir = cli.source.clone();
let mut origin = packaged_dir.as_deref().and_then(GitOrigin::detect);
// The probe data read above describes `detect_dir` — the cwd unless
// `--source` pointed somewhere. When the source-location answer just
// redirected the wizard to another directory, re-run the detection +
// probe there (the same code path as the initial pass) so every
// following probe-derived default — upstream version, description,
// homepage, license, command, the pkg-config hint — describes the
// project actually being packaged. The detected directory itself is
// never re-probed (the data is already fresh and the scan is not free),
// and explicit flags keep winning throughout: the probe only ever feeds
// the defaults of questions without a flag answer.
if let Some(chosen) = packaged_dir.as_deref()
&& !same_directory(chosen, &detect_dir)
{
let (_, refreshed) = detect_and_probe(chosen);
probe = refreshed;
// The license sniff and the pkg-config hint read the same directory
// the probe data now comes from.
detect_dir = chosen.to_path_buf();
}
// 4. Upstream version: probed project version, then the tag HEAD sits
// 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() {
let default = probe
.as_ref()
.and_then(|p| p.version.clone())
.or_else(|| origin.as_ref().and_then(GitOrigin::head_tag_version))
.or_else(|| origin.as_ref().and_then(GitOrigin::git_version))
.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(prompter, "Upstream version", &default, validate)?;
cli.upstream_version = Some(answer.clone());
// The typed version names an existing tag HEAD is not on: offer to
// check the release out (the packaging then matches the version).
if let Some(origin_state) = &origin
&& let Some(tag) = origin_state.tag_for_version(&answer)
&& Some(tag) != origin_state.head_tag.as_deref()
{
let question = format!(
"Version {answer} matches tag {tag}, but HEAD is not that \
tag. Check out {tag} now?"
);
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!(
"Checked out {tag} (detached HEAD — expected when \
packaging a release)"
);
// HEAD moved: the release/git-archive choices below must
// reflect the checkout.
origin = GitOrigin::detect(dir);
}
// Declined (or non-TTY): keep the current tree and the typed
// version.
}
}
// 4b. Orig-tarball origin (quilt + existing project only): one select
// question, options pre-ordered by what the detection found. The
// release download is explicit network consent; a skeleton quilt
// tree has no upstream history and is only ever snapshotted.
if source_format == SourceFormat::Quilt && packaged_dir.is_some() {
let choices = orig_origin_choices(origin.as_ref());
let labels: Vec<String> = choices.iter().map(|(label, _)| label.clone()).collect();
let head_tagged = origin
.as_ref()
.and_then(|o| o.head_tag.as_deref())
.is_some();
let default = if head_tagged {
labels[0].clone()
} else {
"Snapshot this working tree".to_string()
};
let answer = select_from(prompter, ORIG_LABEL, &labels, &default, |answer| {
labels.contains(&answer.to_string())
})?;
let chosen = choices
.iter()
.find(|(label, _)| label == &answer)
.map(|(_, value)| *value)
.expect("answer comes from the choice list");
cli.orig_from = Some(chosen.to_string());
if chosen == "path" {
let validator = |path: &str| options::validate_orig_path(path);
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(prompter, "Debian revision", "1", validate_revision_answer)?;
cli.revision = answer.parse::<u32>().ok();
}
// 6. One-line description (required: an empty answer re-asks).
if cli.description.is_none() {
let default = probe
.as_ref()
.and_then(|p| p.description.clone())
.unwrap_or_default();
let validate = required_answer("the description");
let answer = ask_text(prompter, "One-line description", &default, validate)?;
cli.description = Some(answer);
}
// 7. Homepage (blank skips; a probed default must still be a valid URL).
if cli.homepage.is_none() {
let default = probe
.as_ref()
.and_then(|p| p.homepage.clone())
.unwrap_or_default();
let validate = |url: &str| {
if url.is_empty() {
Ok(())
} else {
options::validate_homepage(url)
}
};
let answer = ask_text(prompter, "Homepage (blank to skip)", &default, validate)?;
if !answer.is_empty() {
cli.homepage = Some(answer);
}
}
// 8. License: curated SPDX menu plus a free-text entry. The default
// comes from the project metadata (Cargo.toml / pyproject.toml), then
// from sniffing the LICENSE/COPYING file.
if cli.license.is_none() {
let detected = probe
.as_ref()
.and_then(|p| p.license.clone())
.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(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(
prompter,
"License (SPDX identifier)",
&custom_default,
validate,
)?;
cli.license = Some(license);
} else {
cli.license = Some(answer);
}
}
// 9. Command name (skipped for the empty template, where nothing is
// installed). Typed answers and the offered default go through the
// same `validate_command` bar as `resolve` applies (which also
// requires a non-empty answer), so an unusable probe is withheld and
// invalid input re-asks here instead of failing late in `resolve`.
if cli.command.is_none() && template != TemplateId::EMPTY {
let default = probe
.as_ref()
.and_then(|p| p.command.clone())
.unwrap_or_else(|| cli.name.clone().unwrap_or_default());
let command = ask_text(
prompter,
"Command name",
&default,
options::validate_command,
)?;
cli.command = Some(command);
}
// 10. Maintainer, defaulting to the DEBEMAIL/git-config identity. The
// 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() {
let candidate = crate::changelog::get_maintainer_info()
.map(|(name, email)| format!("{name} <{email}>"))
.unwrap_or_default();
let default = if options::parse_maintainer(&candidate).is_ok() {
candidate
} else {
String::new()
};
if default.is_empty() {
log::warn!(
"Could not determine a maintainer default (no DEBFULLNAME/\
DEBEMAIL and no git user config): answer as 'Name <email>'"
);
}
let validate = |answer: &str| options::parse_maintainer(answer).map(|_| ());
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 — 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();
if let Some(pos) = options.iter().position(|d| d == "ubuntu")
&& pos > 0
{
let ubuntu = options.remove(pos);
options.insert(0, ubuntu);
}
let default = if options.contains(&vendor) {
vendor
} else {
"ubuntu".to_string()
};
let answer = select_from(prompter, DIST_LABEL, &options, &default, |answer| {
options.contains(&answer.to_string())
})?;
cli.dist = Some(answer);
}
let dist = cli
.dist
.clone()
.unwrap_or_else(|| crate::build::env::current_vendor().to_lowercase());
// 12. Target series: the development series first, preselected.
if cli.series.is_none() {
match crate::distro_info::get_ordered_series_name(&dist).await {
Ok(series) if !series.is_empty() => {
let answer = prompter.select(SERIES_LABEL, &series, &series[0])?;
cli.series = Some(answer);
}
_ => {
log::warn!(
"Could not fetch the series list for '{dist}'; \
defaulting to its development series"
);
}
}
}
// 13. Metapackage Depends (empty template only).
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,
)?;
if !answer.trim().is_empty() {
cli.depends = vec![answer];
}
}
// 14. Git init — asked only when a git init would actually happen
// (outside any repository, without `--no-git`). Inside an existing
// git work tree there is nothing to initialize: the question is
// skipped, the run assumes No and the scaffold logs its usual skip
// (the .gitignore files are written regardless). The probe looks at
// the packaged directory — or, for a skeleton, at the directory it
// would be created in.
let probe_dir = cli.source.clone().unwrap_or_else(|| cwd.clone());
let inside_repo = cli.git && git::inside_work_tree(&probe_dir);
if git_init_answer(!cli.git, inside_repo).is_some() {
// `--no-git` already declined, or there is nothing to initialize.
cli.git = false;
} else {
cli.git = prompter.confirm("Initialize a git repository?", cli.git)?;
}
// Resolve through the same pipeline as the non-interactive path: one
// source of truth for defaults and validation.
let mut opts = options::resolve(cli).await?;
// The meson/cmake opt-in question of the spec's template table: does the
// 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)
&& prompter.confirm(
"Does the build resolve libraries through pkg-config (add it to Build-Depends)?",
pkg_config_hint(&detect_dir, template),
)?
{
opts.pkg_config = true;
}
// Wizard-only extras (default off).
if template != TemplateId::EMPTY
&& prompter.confirm(
"Add an autopkgtest smoke test (debian/tests/control)?",
false,
)?
{
opts.autopkgtest = true;
}
if let Some(watch) = watch_template(opts.homepage.as_deref())
&& 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).
prompter.present(&summary_text(&opts, toolchain_pin.as_deref()));
if !prompter.confirm("Generate?", true)? {
return Err("Aborted: nothing was written to disk.".into());
}
Ok(opts)
}
/// The post-scaffold verification offers (spec "Verification" steps 2–3),
/// interactive only and skipped with `--no-verify`: the source build
/// (`pkh build`, offered yes) and the binary build (`pkh deb`, offered no —
/// it needs network + build deps). A failed verification build never undoes
/// the scaffold: the error is printed together with the manual next steps.
///
/// When the scaffold's vendoring step failed (`outcome`), a prominent notice
/// states that the tree will not build until the dependencies are vendored —
/// printed with or without a TTY — and the build offer is reworded with its
/// default flipped to *no*.
pub async fn offer_verification(
opts: &NewOptions,
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);
let display = if display.is_empty() {
".".to_string()
} else {
display
};
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.
prompter.present("");
log::warn!(
"The Cargo dependencies could NOT be vendored: this package will \
not build until the vendoring is completed by hand:\n\
\x20 1. `cd {display} && cargo vendor`\n\
\x20 2. add the printed source replacement to .cargo/config.toml, \
plus `[net] offline = true`"
);
prompter.present("");
}
if no_verify || !prompter.interactive() {
return;
}
let build_offer = if outcome.vendoring_failed {
"Verify with `pkh build` now? (it will fail until dependencies are vendored)"
} else {
"Verify with `pkh build` now?"
};
let verify_source = match prompter.confirm(build_offer, !outcome.vendoring_failed) {
Ok(answer) => answer,
Err(_) => return,
};
if !verify_source {
return;
}
let ui = std::sync::Arc::new(crate::ui::deb::DebUi::new(multi));
if let Err(e) = crate::build::build_source_package(crate::build::BuildSourceOptions {
source: Some(tree.clone()),
options: crate::build::SourceBuildOptions::default(),
view: &*ui,
prompter,
}) {
log::error!("Verification source build failed: {e}");
log::info!(
"The scaffolded tree is intact. Inspect it, then retry with \
`cd {display} && pkh build`."
);
log::info!(
"Hint: failures here usually come from a missing build dependency \
or build file, not from the scaffold itself; check debian/control \
and the template's build file."
);
return;
}
let verify_deb = match prompter.confirm(
"Verify with `pkh deb` now? (needs network + build deps)",
false,
) {
Ok(answer) => answer,
Err(_) => return,
};
if !verify_deb {
return;
}
let view = crate::ui::deb::DebUi::new(multi);
if let Err(e) = crate::deb::build_binary_package(crate::deb::DebBuildOptions {
series: Some(opts.series.clone()),
cwd: Some(tree.clone()),
view: &view,
..Default::default()
})
.await
{
log::error!("Verification binary build failed: {e}");
log::info!(
"The scaffolded tree is intact. Once the build dependencies are \
available, retry with `cd {display} && pkh deb`."
);
log::info!(
"Hint: when a build dependency is missing from the {} archive, \
`pkh deb --inject <package>` makes it available in the build \
environment (e.g. a PEP 517 backend like python3-poetry-core).",
opts.series
);
}
}
/// Detection + template probe of one candidate directory: the probe data
/// exists only for a confidently detected single-template project. The one
/// code path for both wizard passes — the initial sweep of the detected
/// directory and the re-probe when the source-location answer redirects the
/// wizard elsewhere.
fn detect_and_probe(dir: &std::path::Path) -> (Detection, Option<ProbeResult>) {
let detection = detect::detect(dir);
let probe = match &detection {
Detection::Single(id) => templates::get(*id).and_then(|t| t.probe(dir)),
_ => None,
};
(detection, probe)
}
/// Whether two paths name the same directory: lexical equality first, then
/// (for spellings like `.` next to the absolute cwd) the canonicalized
/// forms. A path that cannot be canonicalized only ever equals itself.
fn same_directory(a: &std::path::Path, b: &std::path::Path) -> bool {
if a == b {
return true;
}
match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
/// The preselected default of the pkg-config opt-in question: whether the
/// project's build file hints at pkg-config usage (`dependency(` in
/// meson.build, `pkg_check_modules` / `find_package(PkgConfig` in
/// CMakeLists.txt).
fn pkg_config_hint(dir: &std::path::Path, template: TemplateId) -> bool {
let (file, needles): (&str, &[&str]) = match template {
TemplateId::MESON => ("meson.build", &["dependency("]),
TemplateId::CMAKE => (
"CMakeLists.txt",
&[
"pkg_check_modules",
"find_package(pkgconfig",
"find_package(pkg_config",
],
),
_ => return false,
};
std::fs::read_to_string(dir.join(file))
.map(|content| {
let lower = content.to_ascii_lowercase();
needles.iter().any(|needle| lower.contains(needle))
})
.unwrap_or(false)
}
/// The default package name: the detected project's name, else the
/// sanitized basename of the current directory.
fn default_package_name(cwd: &std::path::Path, probe: Option<&ProbeResult>) -> String {
probe
.and_then(|p| p.name.as_deref())
.and_then(options::sanitize_name)
.or_else(|| {
cwd.file_name()
.and_then(std::ffi::OsStr::to_str)
.and_then(options::sanitize_name)
})
.unwrap_or_default()
}
/// The language menu: detected candidates first (in their detection order),
/// then every other template in registry order.
fn language_menu(candidates: &[TemplateId]) -> Vec<String> {
candidates
.iter()
.copied()
.chain(
TemplateId::all()
.iter()
.copied()
.filter(|id| !candidates.contains(id)),
)
.map(|id| id.display_name().to_string())
.collect()
}
/// What the language step does with the detection result: how an explicit
/// `--lang` flag combines with it (flag > detected/probe > default).
#[derive(Debug, PartialEq, Eq)]
enum LanguageChoice {
/// An explicit `--lang` wins: it is kept as-is, the question is never
/// re-asked and the detection stays informational.
Flag,
/// No flag, confident detection, packaging the detected directory: the
/// detection decides.
Detected(TemplateId),
/// Ask the question, preselecting the detected ecosystem (a skeleton
/// was asked for); `None` when nothing was detected.
Ask(Option<TemplateId>),
/// Ask the question with the ambiguous candidates listed first (the
/// highest-precedence one preselected).
Ambiguous(Vec<TemplateId>),
}
/// The language step's decision for one wizard run. With the flag absent
/// this mirrors the historical behavior exactly; the flag always wins.
fn language_choice(
flag: Option<&str>,
detection: &Detection,
detection_decides: bool,
) -> LanguageChoice {
if flag.is_some() {
return LanguageChoice::Flag;
}
match detection {
Detection::Single(id) if detection_decides => LanguageChoice::Detected(*id),
Detection::Single(id) => LanguageChoice::Ask(Some(*id)),
Detection::Ambiguous(candidates) => LanguageChoice::Ambiguous(candidates.clone()),
Detection::Empty => LanguageChoice::Ask(None),
}
}
/// The license menu: the `menu` labels of the bundled license table
/// (`data/licenses.yml`) plus the free-text entry.
fn license_menu() -> Vec<String> {
licenses::entries()
.iter()
.map(|entry| entry.menu.clone())
.chain(std::iter::once(LICENSE_OTHER.to_string()))
.collect()
}
/// The defaults of the license question for a probed SPDX identifier: the
/// matching curated entry (case-insensitive over the menu labels) is
/// preselected; anything else preselects the free-text entry prefilled with
/// the probe. Without a probe the curated list defaults to MIT (the first
/// menu entry of the bundled table).
fn license_question_default(probe_license: Option<&str>) -> (String, String) {
match probe_license {
Some(license) => {
match licenses::entries()
.iter()
.find(|entry| entry.menu.eq_ignore_ascii_case(license))
{
Some(known) => (known.menu.clone(), String::new()),
None => (LICENSE_OTHER.to_string(), license.to_string()),
}
}
None => ("MIT".to_string(), String::new()),
}
}
/// Ask the language question until a known template label (or CLI
/// identifier) is answered — the selector allows typing arbitrary text.
fn select_template(
prompter: &dyn Prompter,
options: &[String],
default: &str,
) -> Result<TemplateId, Box<dyn Error>> {
loop {
let answer = prompter.select(LANGUAGE_LABEL, options, default)?;
match TemplateId::from_label(&answer) {
Some(id) => return Ok(id),
None => log::warn!(
"'{answer}' is not a known template; pick one from the list \
(Tab completes its name)"
),
}
}
}
/// 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 = prompter.select(label, options, default)?;
if accept(&answer) {
return Ok(answer);
}
log::warn!("'{answer}' is not one of the offered answers; pick from the list");
}
}
/// 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: &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: &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
/// default": an empty answer falls back to the default (`Esc` keeps its
/// prompt-level meaning of restoring it). Both the typed answer and the
/// default go through `validate`: a probed default that fails validation is
/// never offered ([`offered_default`]), and an empty answer without a valid
/// 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(
prompter: &dyn Prompter,
label: &str,
default: &str,
validate: impl Fn(&str) -> Result<(), String> + 'static,
) -> 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| {
if answer.is_empty() {
Ok(())
} else {
validate(answer)
}
};
loop {
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() {
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.
fn required_answer(what: &str) -> impl Fn(&str) -> Result<(), String> + '_ {
move |answer: &str| {
if answer.trim().is_empty() {
Err(format!("{what} must not be empty"))
} else {
Ok(())
}
}
}
/// Validator of the source-directory answer: an existing directory.
fn validate_directory_answer(path: &str) -> Result<(), String> {
if path.trim().is_empty() {
return Err("a directory path is required".to_string());
}
if std::path::Path::new(path).is_dir() {
Ok(())
} else {
Err(format!("'{path}' is not a directory"))
}
}
/// Validator of the Debian revision answer: a positive integer.
fn validate_revision_answer(answer: &str) -> Result<(), String> {
match answer.parse::<u32>() {
Ok(0) => Err("the Debian revision must be at least 1".to_string()),
Ok(_) => Ok(()),
Err(_) => Err(format!(
"'{answer}' is not a valid Debian revision: expected a positive integer"
)),
}
}
/// The choices of the orig-tarball-origin question for a quilt packaging of
/// an existing directory: `(menu label, --orig-from value)` pairs,
/// pre-ordered by what the git origin detection found — the release
/// download and `git archive` only exist when HEAD sits exactly on a tag,
/// the release download additionally needs a recognized forge. The working
/// tree snapshot is always available (and is the implicit default when HEAD
/// is not on a tag).
fn orig_origin_choices(origin: Option<&GitOrigin>) -> Vec<(String, &'static str)> {
let mut choices: Vec<(String, &'static str)> = Vec::new();
if let Some(origin) = origin
&& let Some(tag) = &origin.head_tag
{
if let Some(forge) = &origin.forge {
choices.push((
format!(
"Download the upstream release tarball from {} ({tag})",
forge.host()
),
"release",
));
}
choices.push((
format!("Create it from the git tag ({tag}, git archive)"),
"git",
));
}
choices.push(("Use a tarball I provide".to_string(), "path"));
choices.push(("Snapshot this working tree".to_string(), "snapshot"));
choices
}
/// The git-init answer when the question must not be asked: `Some(false)`
/// both for `--no-git` (already declined) and inside an existing git work
/// tree (nothing to initialize — the scaffold logs the skip and writes the
/// `.gitignore` files anyway). `None` asks the wizard question, i.e.
/// whenever a git init would actually happen (fresh skeleton or packaged
/// directory outside any repository).
fn git_init_answer(no_git: bool, inside_repo: bool) -> Option<bool> {
(no_git || inside_repo).then_some(false)
}
/// A `debian/watch` template for GitHub/GitLab-hosted projects; `None` when
/// the homepage is not one of those hosts (the wizard skips the question).
pub fn watch_template(homepage: Option<&str>) -> Option<String> {
let homepage = homepage?;
let (scheme, path) = homepage.split_once("://")?;
if scheme != "https" && scheme != "http" {
return None;
}
let (host, repo_path) = path.split_once('/')?;
let host = host.to_ascii_lowercase();
if host != "github.com" && host != "gitlab.com" {
return None;
}
let mut segments = repo_path.trim_end_matches('/').split('/');
let owner = segments.next()?.trim_end_matches(".git");
let repo = segments.next()?.trim_end_matches(".git");
if owner.is_empty() || repo.is_empty() {
return None;
}
Some(format!(
"version=4\nhttps://{host}/{owner}/{repo}/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
))
}
/// The pre-flight summary screen shown before the final `Generate?`
/// confirmation (spec transcript): identity line, template/license/maintainer
/// line, the generated-file overview and — for a metapackage — the Depends
/// payload, for a skeleton — the upstream files that will be created, and
/// for rust — the probed toolchain pin (when any; the chroot build ignores
/// it) plus a warning when dependencies cannot be vendored on this host.
pub fn summary_text(opts: &NewOptions, toolchain_pin: Option<&str>) -> String {
let template = templates::get(opts.template);
let mut lines = Vec::new();
lines.push("────────────────────────────────────────────".to_string());
lines.push(format!(
" {} {} · builds for {}/{}",
opts.name,
opts.full_version(),
opts.dist,
opts.series
));
lines.push(format!(
" {} · {} · {} <{}>",
opts.template.display_name(),
opts.license.spdx(),
opts.maintainer.0,
opts.maintainer.1
));
if let Some(template) = template {
lines.push(format!(
" debian/control Source + 1 binary (Architecture: {})",
template.architecture(opts)
));
if opts.template == TemplateId::EMPTY {
// The Depends list is the payload of the metapackage flavor.
if !opts.depends.is_empty() {
lines.push(format!(" Depends {}", opts.depends.join(", ")));
}
} else if opts.template == TemplateId::RUST {
// Nothing is vendored yet at this point: only announce that the
// generation will attempt it.
lines.push(
" debian/rules cargo build --release --offline (vendored at generation)"
.to_string(),
);
// A pinned rust-toolchain.toml does not reach the chroot build:
// flagged here so a too-old pin is no surprise later.
if let Some(pin) = toolchain_pin {
lines.push(format!(
" rust-toolchain {pin} (ignored by the chroot build)"
));
}
} else {
lines.push(format!(" debian/rules {}", template.rules_dh_line()));
}
}
lines.push(format!(
" debian/source/format {}",
opts.source_format.deb_string()
));
if let Some(orig) = &opts.orig {
lines.push(format!(" orig tarball {}", orig.label()));
}
let distribution = if opts.release {
opts.series.as_str()
} else {
crate::distro_info::UNRELEASED
};
lines.push(format!(
" debian/changelog {} {distribution}, Initial release",
opts.full_version()
));
lines.push(format!(
" debian/copyright {} (DEP-5)",
opts.license.spdx()
));
if opts.autopkgtest {
lines.push(" debian/tests autopkgtest smoke test".to_string());
}
if opts.watch.is_some() {
lines.push(" debian/watch release watcher".to_string());
}
if matches!(opts.source_dir, SourceDir::Skeleton)
&& let Some(template) = template
{
let names: Vec<String> = template
.skeleton(opts)
.iter()
.map(|file| file.path.clone())
.collect();
if !names.is_empty() {
lines.push(format!(" + {} (new skeleton)", names.join(", ")));
}
}
if opts.template == TemplateId::RUST && templates::find_on_path("cargo").is_none() {
lines.push(
" ! cargo not found on PATH: dependencies cannot be vendored at \
scaffold time; the package will not build until you run \
`cargo vendor`"
.to_string(),
);
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new::options::License;
use crate::new::options::TemplateId as Tid;
fn opts(template: Tid) -> NewOptions {
NewOptions {
name: "mytool".into(),
template,
source_dir: options::SourceDir::Skeleton,
upstream_version: "0.1.0".into(),
revision: 1,
summary: "A tool that does one thing well".into(),
long_description: "A tool that does one thing well".into(),
homepage: None,
license: License::Mit,
command: "mytool".into(),
maintainer: ("Jane Doe".into(), "jane@example.com".into()),
dist: "ubuntu".into(),
series: "resolute".into(),
release: false,
depends: Vec::new(),
source_format: options::SourceFormat::Native,
orig: None,
git: true,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
#[test]
fn default_package_name_prefers_probe_then_basename() {
let probe = ProbeResult {
name: Some("My_Tool".to_string()),
..Default::default()
};
let cwd = std::path::Path::new("/home/user/projects");
assert_eq!(
default_package_name(cwd, Some(&probe)),
"my-tool".to_string()
);
// No probe: the directory basename, sanitized.
assert_eq!(
default_package_name(std::path::Path::new("/tmp/My Tool"), None),
"my-tool".to_string()
);
// Nothing sane anywhere: empty (the validator forces an answer).
assert_eq!(default_package_name(std::path::Path::new("/"), None), "");
}
#[test]
fn language_menu_lists_candidates_first() {
let menu = language_menu(&[Tid::MAKEFILE, Tid::RUST]);
assert_eq!(menu[0], "Generic (Makefile)");
assert_eq!(menu[1], "Rust (Cargo.toml)");
// The remaining seven follow in registry order, no duplicates.
assert_eq!(menu.len(), Tid::all().len());
let unique: std::collections::HashSet<&String> = menu.iter().collect();
assert_eq!(unique.len(), menu.len());
}
/// An explicit `--lang` wins over any detection (the flag outranks the
/// detection and the defaults): the flag is kept and the question is
/// never re-asked, whatever the detection found (regression: a
/// confident detection used to overwrite the flag and an ambiguous one
/// re-asked the question).
#[test]
fn language_choice_flag_wins_over_detection() {
let detections = [
Detection::Single(Tid::RUST),
Detection::Ambiguous(vec![Tid::RUST, Tid::PYTHON]),
Detection::Empty,
];
for detection in &detections {
for decides in [false, true] {
assert_eq!(
language_choice(Some("python"), detection, decides),
LanguageChoice::Flag,
"detection {detection:?}, decides {decides}"
);
}
}
}
/// Without the flag the detection behaves exactly as before: it decides
/// for a confident detection (packaging the detected directory), asks
/// with a preselection for a skeleton run, lists the candidates first
/// when ambiguous, and falls back to the plain menu otherwise.
#[test]
fn language_choice_without_flag_follows_detection() {
assert_eq!(
language_choice(None, &Detection::Single(Tid::RUST), true),
LanguageChoice::Detected(Tid::RUST)
);
// Skeleton run: ask, preselecting the detected ecosystem.
assert_eq!(
language_choice(None, &Detection::Single(Tid::RUST), false),
LanguageChoice::Ask(Some(Tid::RUST))
);
assert_eq!(
language_choice(
None,
&Detection::Ambiguous(vec![Tid::GO, Tid::PYTHON]),
true
),
LanguageChoice::Ambiguous(vec![Tid::GO, Tid::PYTHON])
);
// Nothing detected: plain menu, the empty template preselected.
assert_eq!(
language_choice(None, &Detection::Empty, false),
LanguageChoice::Ask(None)
);
}
#[test]
fn license_menu_and_defaults() {
let menu = license_menu();
assert_eq!(menu.len(), licenses::entries().len() + 1);
assert_eq!(menu[0], "MIT");
assert_eq!(menu.last().unwrap(), LICENSE_OTHER);
// No probe: MIT preselected, no custom prefill.
assert_eq!(
license_question_default(None),
("MIT".to_string(), String::new())
);
// Curated probe: matched case-insensitively.
assert_eq!(
license_question_default(Some("apache-2.0")),
("Apache-2.0".to_string(), String::new())
);
// Unusual probe: free-text entry prefilled.
assert_eq!(
license_question_default(Some("Zlib")),
(LICENSE_OTHER.to_string(), "Zlib".to_string())
);
}
#[test]
fn watch_template_hosts() {
assert_eq!(
watch_template(Some("https://github.com/foo/bar")),
Some(
"version=4\nhttps://github.com/foo/bar/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
.to_string()
)
);
assert_eq!(
watch_template(Some("https://gitlab.com/foo/bar/")),
Some(
"version=4\nhttps://gitlab.com/foo/bar/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
.to_string()
)
);
// .git suffixes and deeper paths are handled.
assert_eq!(
watch_template(Some("https://github.com/foo/bar.git/tree")),
Some(
"version=4\nhttps://github.com/foo/bar/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
.to_string()
)
);
// Other hosts, no homepage, or no repo path: skipped.
assert!(watch_template(Some("https://example.com/foo/bar")).is_none());
assert!(watch_template(Some("https://github.com/foo")).is_none());
assert!(watch_template(None).is_none());
}
/// The orig-origin choices are pre-ordered by what the detection found:
/// release download only with a forge, git archive only on a tag,
/// tarball/snapshot always; snapshot last (the off-tag default).
#[test]
fn orig_origin_choices_preorder_by_detection() {
use crate::new::origin::Forge;
let tagged_forge = GitOrigin {
forge: Some(Forge::parse("https://github.com/foo/bar").unwrap()),
head_tag: Some("v1.4.0".into()),
..Default::default()
};
let choices = orig_origin_choices(Some(&tagged_forge));
assert_eq!(choices.len(), 4);
assert_eq!(choices[0].1, "release");
assert!(choices[0].0.contains("github.com"));
assert!(choices[0].0.contains("v1.4.0"));
assert_eq!(choices[1].1, "git");
assert!(choices[1].0.contains("git archive"));
assert_eq!(choices[2].1, "path");
assert_eq!(choices[3].1, "snapshot");
// Tag without a recognized forge: no download option.
let tagged = GitOrigin {
head_tag: Some("1.0.0".into()),
..Default::default()
};
let choices = orig_origin_choices(Some(&tagged));
assert_eq!(choices.len(), 3);
assert_eq!(choices[0].1, "git");
assert_eq!(choices[1].1, "path");
assert_eq!(choices[2].1, "snapshot");
// Off a tag (or not even a repo): tarball + snapshot only.
let off_tag = GitOrigin {
last_tag: Some("v1.0.0".into()),
..Default::default()
};
let choices = orig_origin_choices(Some(&off_tag));
assert_eq!(choices.len(), 2);
assert_eq!(choices[0].1, "path");
assert_eq!(choices[1].1, "snapshot");
assert_eq!(orig_origin_choices(None).len(), 2);
}
/// The summary surfaces the derived source format and, for quilt, the
/// planned orig origin.
#[test]
fn summary_screen_shows_format_and_orig_origin() {
// Native skeleton: the format row, no orig row.
let text = summary_text(&opts(Tid::SHELL), None);
assert!(text.contains("debian/source/format 3.0 (native)"), "{text}");
assert!(!text.contains("orig tarball"), "{text}");
// Quilt over an existing project: both rows.
let mut quilt = opts(Tid::SHELL);
quilt.source_dir = options::SourceDir::Here;
quilt.source_format = options::SourceFormat::Quilt;
quilt.orig = Some(options::OrigOrigin::GitArchive {
tag: "v1.4.0".to_string(),
});
let text = summary_text(&quilt, None);
assert!(text.contains("debian/source/format 3.0 (quilt)"), "{text}");
assert!(
text.contains("orig tarball git archive (v1.4.0)"),
"{text}"
);
// The release-download label of the origin matrix.
let mut release = quilt.clone();
release.orig = Some(options::OrigOrigin::Release {
tag: "v0.14.0".to_string(),
forge: crate::new::origin::Forge::parse("https://gitlab.com/foo/bar").unwrap(),
});
let text = summary_text(&release, None);
assert!(
text.contains("orig tarball release download (v0.14.0)"),
"{text}"
);
}
#[test]
fn summary_screen_skeleton() {
let text = summary_text(&opts(Tid::MAKEFILE), None);
assert!(
text.contains("mytool 0.1.0-1 · builds for ubuntu/resolute"),
"{text}"
);
assert!(
text.contains("Generic (Makefile) · MIT · Jane Doe <jane@example.com>"),
"{text}"
);
assert!(
text.contains("debian/control Source + 1 binary (Architecture: any)"),
"{text}"
);
assert!(text.contains("debian/rules dh $@"), "{text}");
assert!(
text.contains("debian/changelog 0.1.0-1 UNRELEASED, Initial release"),
"{text}"
);
assert!(text.contains("debian/copyright MIT (DEP-5)"), "{text}");
assert!(
text.contains("+ hello.c, Makefile (new skeleton)"),
"{text}"
);
// No extra files unless asked for.
assert!(!text.contains("debian/tests"));
assert!(!text.contains("debian/watch"));
}
#[test]
fn summary_screen_metapackage_shows_depends() {
let mut o = opts(Tid::EMPTY);
o.depends = vec!["hello".to_string(), "hello-data (>= 1.0)".to_string()];
o.source_dir = options::SourceDir::Here;
let text = summary_text(&o, None);
assert!(text.contains("Architecture: all"), "{text}");
assert!(
text.contains("Depends hello, hello-data (>= 1.0)"),
"{text}"
);
// Build info is replaced by the Depends payload; no skeleton line.
assert!(!text.contains("debian/rules"), "{text}");
assert!(!text.contains("(new skeleton)"), "{text}");
}
#[test]
fn summary_screen_release_and_extras() {
let mut o = opts(Tid::SHELL);
o.release = true;
o.autopkgtest = true;
o.watch = Some("version=4\n".to_string());
let text = summary_text(&o, None);
assert!(text.contains("0.1.0-1 resolute, Initial release"), "{text}");
assert!(
text.contains("debian/tests autopkgtest smoke test"),
"{text}"
);
assert!(text.contains("debian/watch release watcher"), "{text}");
}
/// The rust summary only announces the vendoring attempt of the
/// generation, it must not assert an outcome that has not been tried
/// yet (regression: it claimed "(vendored)" before generating).
#[test]
fn summary_screen_rust_does_not_presume_vendoring() {
let text = summary_text(&opts(Tid::RUST), None);
assert!(
text.contains("cargo build --release --offline (vendored at generation)"),
"{text}"
);
assert!(!text.contains("(vendored)"), "{text}");
}
/// A probed rust toolchain pin surfaces in the summary as its own row
/// (rust template only), flagged as ignored by the chroot build.
#[test]
fn summary_screen_shows_the_toolchain_pin() {
let text = summary_text(&opts(Tid::RUST), Some("1.98.0"));
assert!(
text.contains("rust-toolchain 1.98.0 (ignored by the chroot build)"),
"{text}"
);
// No pin, no row.
assert!(!summary_text(&opts(Tid::RUST), None).contains("rust-toolchain"));
// A pin under a template other than rust is not shown either (the
// pin only matters for a cargo build).
assert!(!summary_text(&opts(Tid::GO), Some("1.98.0")).contains("rust-toolchain"));
}
/// The git-init question is only asked when a git init would actually
/// happen: inside an existing work tree it is skipped with the assumed
/// No (with or without `--no-git`), and `--no-git` has already answered
/// it on its own.
#[test]
fn git_init_question_decision() {
assert_eq!(git_init_answer(false, true), Some(false));
assert_eq!(git_init_answer(true, true), Some(false));
assert_eq!(git_init_answer(true, false), Some(false));
// Outside any repository without --no-git: the wizard asks.
assert_eq!(git_init_answer(false, false), None);
}
#[test]
fn answer_validators() {
assert!(validate_revision_answer("1").is_ok());
assert!(validate_revision_answer("0").is_err());
assert!(validate_revision_answer("x").is_err());
assert!(validate_directory_answer("/tmp").is_ok());
assert!(validate_directory_answer("").is_err());
assert!(validate_directory_answer("/definitely/not/here").is_err());
assert!(required_answer("x")("").is_err());
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]
fn select_labels_carry_their_own_separator() {
// 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 {
assert!(
label.ends_with(": ") || label.ends_with("? "),
"select label {label:?} lacks a trailing separator"
);
}
}
/// The probe helper is the same code path for the initial pass and the
/// re-probe: pointed at directory A it reports A's project, pointed at
/// directory B it reports B's. So when the source-location answer
/// redirects the wizard from the detected directory to another one, the
/// refreshed defaults (upstream version, description, homepage, license)
/// describe the project actually being packaged — regression: they used
/// to keep coming from the original directory.
#[test]
fn detect_and_probe_reports_the_directory_it_is_given() {
use tempfile::tempdir;
let manifest = |name: &str, version: &str, license: &str| {
format!(
"[package]\nname = \"{name}\"\nversion = \"{version}\"\n\
edition = \"2021\"\ndescription = \"{name} does {name} \
things\"\nhomepage = \"https://{name}.example.com\"\n\
license = \"{license}\"\n"
)
};
let dir_a = tempdir().unwrap();
std::fs::write(
dir_a.path().join("Cargo.toml"),
manifest("alpha", "0.1.0", "MIT"),
)
.unwrap();
let dir_b = tempdir().unwrap();
std::fs::write(
dir_b.path().join("Cargo.toml"),
manifest("beta", "2.9.9", "GPL-3.0+"),
)
.unwrap();
// The initial pass over dir A.
let (detection_a, probe_a) = detect_and_probe(dir_a.path());
assert_eq!(detection_a, Detection::Single(Tid::RUST));
let probe_a = probe_a.expect("dir A is a rust project");
assert_eq!(probe_a.name.as_deref(), Some("alpha"));
assert_eq!(probe_a.version.as_deref(), Some("0.1.0"));
assert_eq!(probe_a.license.as_deref(), Some("MIT"));
// The user chose dir B instead: the refreshed probe comes from B,
// never from A.
let (detection_b, probe_b) = detect_and_probe(dir_b.path());
assert_eq!(detection_b, Detection::Single(Tid::RUST));
let probe_b = probe_b.expect("dir B is a rust project");
assert_eq!(probe_b.name.as_deref(), Some("beta"));
assert_eq!(probe_b.version.as_deref(), Some("2.9.9"));
assert_eq!(
probe_b.description.as_deref(),
Some("beta does beta things")
);
assert_eq!(
probe_b.homepage.as_deref(),
Some("https://beta.example.com")
);
assert_eq!(probe_b.license.as_deref(), Some("GPL-3.0+"));
// A directory without project markers probes to nothing (the
// questions fall back to their plain defaults).
let dir_c = tempdir().unwrap();
let (detection_c, probe_c) = detect_and_probe(dir_c.path());
assert_eq!(detection_c, Detection::Empty);
assert!(probe_c.is_none());
}
/// The re-probe skip predicate: the detected/cwd directory never
/// re-probes — lexically identical paths and spellings of the same
/// directory (`.`, a trailing slash) all compare equal, different
/// directories and non-existent paths do not.
#[test]
fn same_directory_compares_spellings_of_one_directory() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let other = tempdir().unwrap();
assert!(same_directory(dir.path(), dir.path()));
// `.` names the current directory.
assert!(same_directory(
std::path::Path::new("."),
&std::env::current_dir().unwrap()
));
assert!(!same_directory(dir.path(), other.path()));
// A path that cannot be canonicalized only ever equals itself.
assert!(!same_directory(dir.path(), &dir.path().join("missing")));
}
}