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.
1661 lines
65 KiB
Rust
1661 lines
65 KiB
Rust
//! 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")));
|
||
}
|
||
}
|