new: add interactive wizard and remaining ecosystem templates
This commit is contained in:
@@ -0,0 +1,948 @@
|
||||
//! The `pkh new` interactive wizard.
|
||||
//!
|
||||
//! [`run`] is the single entry point: on an interactive terminal it asks the
|
||||
//! questions of the spec's "Proposed UX" transcript, fills a
|
||||
//! [`NewCli`] with the answers (explicit flags are never re-asked), and
|
||||
//! reuses [`options::resolve`] as the single source of truth for defaults,
|
||||
//! detection and validation — so the non-interactive and interactive paths
|
||||
//! cannot drift apart. Without a terminal (or with `--defaults`) it goes
|
||||
//! 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 prompt calls live in `run_wizard` and `offer_verification` only;
|
||||
//! everything else in this module is pure and unit-tested.
|
||||
|
||||
use std::error::Error;
|
||||
use std::io::IsTerminal;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use indicatif::MultiProgress;
|
||||
|
||||
use crate::new::detect::{self, Detection};
|
||||
use crate::new::options::{self, NewCli, NewOptions, SourceDir, TemplateId};
|
||||
use crate::new::templates::{self, ProbeResult};
|
||||
use crate::ui::prompt;
|
||||
|
||||
/// 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)";
|
||||
|
||||
/// The curated SPDX identifiers of the license menu (without the free-text
|
||||
/// entry), matching [`options::License::parse`]'s known spellings.
|
||||
pub const KNOWN_LICENSES: [&str; 9] = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"GPL-2.0+",
|
||||
"GPL-3.0+",
|
||||
"LGPL-2.1+",
|
||||
"LGPL-3.0+",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
];
|
||||
|
||||
/// Run the `pkh new` flow: the wizard on an interactive terminal, plain
|
||||
/// [`options::resolve`] otherwise (and with `--defaults`).
|
||||
pub async fn run(cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
||||
if cli.defaults || !is_interactive() {
|
||||
return Ok(options::resolve(cli).await?);
|
||||
}
|
||||
run_wizard(cli).await
|
||||
}
|
||||
|
||||
/// Whether both ends of the terminal are interactive; the wizard and the
|
||||
/// verification offers only run when this holds (the prompts' non-TTY
|
||||
/// fallbacks would otherwise silently take defaults).
|
||||
fn is_interactive() -> bool {
|
||||
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
/// The wizard question flow (spec "Proposed UX"), in order:
|
||||
/// package name, language/build system, source location, upstream version,
|
||||
/// 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) -> Result<NewOptions, Box<dyn Error>> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
let detect_dir = cli.source.clone().unwrap_or_else(|| cwd.clone());
|
||||
let detection = detect::detect(&detect_dir);
|
||||
let probe = match &detection {
|
||||
Detection::Single(id) => templates::get(*id).and_then(|t| t.probe(&detect_dir)),
|
||||
_ => None,
|
||||
};
|
||||
// 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("Package name", &default, options::validate_source_name)?;
|
||||
cli.name = Some(answer);
|
||||
}
|
||||
|
||||
// 2. Language / build system.
|
||||
let mut preselected: Option<TemplateId> = None;
|
||||
match &detection {
|
||||
Detection::Single(id) if detection_decides => {
|
||||
log::info!(
|
||||
"Detected: {} project in {}",
|
||||
id.display_name(),
|
||||
detect_dir.display()
|
||||
);
|
||||
cli.lang = Some(id.as_str().to_string());
|
||||
}
|
||||
Detection::Single(id) => {
|
||||
// A skeleton was asked for: still ask, preselecting the
|
||||
// detected ecosystem.
|
||||
preselected = Some(*id);
|
||||
}
|
||||
Detection::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(&menu, &menu[0])?;
|
||||
cli.lang = Some(id.as_str().to_string());
|
||||
}
|
||||
Detection::Empty => {}
|
||||
}
|
||||
if cli.lang.is_none() {
|
||||
let menu = language_menu(&[]);
|
||||
let default = preselected
|
||||
.unwrap_or(TemplateId::Empty)
|
||||
.display_name()
|
||||
.to_string();
|
||||
let id = select_template(&menu, &default)?;
|
||||
cli.lang = Some(id.as_str().to_string());
|
||||
}
|
||||
let template = TemplateId::parse(cli.lang.as_deref().unwrap_or_default())?;
|
||||
|
||||
// 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("Where is the source code?", &options, default, |answer| {
|
||||
options.contains(&answer.to_string())
|
||||
})?;
|
||||
if answer == SOURCE_HERE {
|
||||
cli.source = Some(cwd.clone());
|
||||
} else if answer == SOURCE_PATH {
|
||||
let validator = |path: &str| validate_directory_answer(path);
|
||||
let path = prompt::text("Source directory", "", Some(&validator))?;
|
||||
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());
|
||||
}
|
||||
|
||||
// 4. Upstream version.
|
||||
if cli.upstream_version.is_none() {
|
||||
let default = probe
|
||||
.as_ref()
|
||||
.and_then(|p| p.version.clone())
|
||||
.unwrap_or_else(|| "0.1.0".to_string());
|
||||
let revision = cli.revision.unwrap_or(1);
|
||||
let answer = ask_text("Upstream version", &default, move |version: &str| {
|
||||
options::validate_upstream_version(version, revision)
|
||||
})?;
|
||||
cli.upstream_version = Some(answer);
|
||||
}
|
||||
|
||||
// 5. Debian revision.
|
||||
if cli.revision.is_none() {
|
||||
let answer = ask_text("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();
|
||||
loop {
|
||||
let answer = ask_text(
|
||||
"One-line description",
|
||||
&default,
|
||||
required_answer("the description"),
|
||||
)?;
|
||||
if !answer.trim().is_empty() {
|
||||
cli.description = Some(answer);
|
||||
break;
|
||||
}
|
||||
log::warn!("A one-line description is required to scaffold a package");
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Homepage.
|
||||
if cli.homepage.is_none() {
|
||||
let default = probe
|
||||
.as_ref()
|
||||
.and_then(|p| p.homepage.clone())
|
||||
.unwrap_or_default();
|
||||
let answer = ask_text(
|
||||
"Homepage (blank to skip)",
|
||||
&default,
|
||||
options::validate_homepage,
|
||||
)?;
|
||||
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("License", &options, &default, |answer| {
|
||||
options.contains(&answer.to_string())
|
||||
})?;
|
||||
if answer == LICENSE_OTHER {
|
||||
let license = loop {
|
||||
let candidate = ask_text(
|
||||
"License (SPDX identifier)",
|
||||
&custom_default,
|
||||
required_answer("the license identifier"),
|
||||
)?;
|
||||
if !candidate.trim().is_empty() {
|
||||
break candidate;
|
||||
}
|
||||
log::warn!("A license identifier is required when picking the free-text entry");
|
||||
};
|
||||
cli.license = Some(license);
|
||||
} else {
|
||||
cli.license = Some(answer);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Command name (skipped for the empty template, where nothing is
|
||||
// installed).
|
||||
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(
|
||||
"Command name",
|
||||
&default,
|
||||
required_answer("the command name"),
|
||||
)?;
|
||||
cli.command = Some(if command.is_empty() {
|
||||
cli.name.clone().unwrap_or_default()
|
||||
} else {
|
||||
command
|
||||
});
|
||||
}
|
||||
|
||||
// 10. Maintainer, defaulting to the DEBEMAIL/git-config identity (an
|
||||
// empty answer re-asks).
|
||||
if cli.maintainer.is_none() {
|
||||
let default = crate::changelog::get_maintainer_info()
|
||||
.map(|(name, email)| format!("{name} <{email}>"))
|
||||
.unwrap_or_default();
|
||||
let maintainer = loop {
|
||||
let answer = ask_text("Maintainer", &default, |answer: &str| {
|
||||
options::parse_maintainer(answer).map(|_| ())
|
||||
})?;
|
||||
if !answer.is_empty() {
|
||||
break answer;
|
||||
}
|
||||
log::warn!(
|
||||
"Could not determine a maintainer default (no DEBFULLNAME/\
|
||||
DEBEMAIL and no git user config): answer as 'Name <email>'"
|
||||
);
|
||||
};
|
||||
cli.maintainer = Some(maintainer);
|
||||
}
|
||||
|
||||
// 11. Target distribution.
|
||||
if cli.dist.is_none() {
|
||||
let vendor = crate::build::env::current_vendor().to_lowercase();
|
||||
let options = vec!["ubuntu".to_string(), "debian".to_string()];
|
||||
let default = if options.contains(&vendor) {
|
||||
vendor
|
||||
} else {
|
||||
"ubuntu".to_string()
|
||||
};
|
||||
let answer = select_from("Target distribution", &options, &default, |answer| {
|
||||
answer == "ubuntu" || answer == "debian"
|
||||
})?;
|
||||
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 = prompt::select("Target series", &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 answer = ask_text(
|
||||
"Depends (metapackage, comma-separated, blank for an empty base)",
|
||||
"",
|
||||
|answer: &str| options::validate_depends(answer).map(|_| ()),
|
||||
)?;
|
||||
if !answer.trim().is_empty() {
|
||||
cli.depends = vec![answer];
|
||||
}
|
||||
}
|
||||
|
||||
// 14. Git init.
|
||||
cli.git = prompt::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)
|
||||
&& prompt::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
|
||||
&& prompt::confirm(
|
||||
"Add an autopkgtest smoke test (debian/tests/control)?",
|
||||
false,
|
||||
)?
|
||||
{
|
||||
opts.autopkgtest = true;
|
||||
}
|
||||
if let Some(watch) = watch_template(opts.homepage.as_deref())
|
||||
&& prompt::confirm("Add a debian/watch release watcher?", false)?
|
||||
{
|
||||
opts.watch = Some(watch);
|
||||
}
|
||||
|
||||
// Summary screen + final confirmation: Ctrl+C or 'n' abort with
|
||||
// nothing written (generation is all-or-nothing later anyway).
|
||||
println!("{}", summary_text(&opts));
|
||||
if !prompt::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.
|
||||
pub async fn offer_verification(opts: &NewOptions, multi: &MultiProgress, no_verify: bool) {
|
||||
if no_verify || !is_interactive() {
|
||||
return;
|
||||
}
|
||||
let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default());
|
||||
let display = crate::ui::display_path(&tree);
|
||||
let display = if display.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
display
|
||||
};
|
||||
|
||||
let verify_source = match prompt::confirm("Verify with `pkh build` now?", true) {
|
||||
Ok(answer) => answer,
|
||||
Err(_) => return,
|
||||
};
|
||||
if !verify_source {
|
||||
return;
|
||||
}
|
||||
|
||||
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
|
||||
if let Err(e) = crate::build::build_source_package(Some(&tree), ui) {
|
||||
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 prompt::confirm(
|
||||
"Verify with `pkh deb` now? (needs network + build deps)",
|
||||
false,
|
||||
) {
|
||||
Ok(answer) => answer,
|
||||
Err(_) => return,
|
||||
};
|
||||
if !verify_deb {
|
||||
return;
|
||||
}
|
||||
|
||||
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
|
||||
if let Err(e) = crate::deb::build_binary_package(
|
||||
None,
|
||||
Some(&opts.series),
|
||||
None,
|
||||
Some(&tree),
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
ui,
|
||||
None,
|
||||
)
|
||||
.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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
.into_iter()
|
||||
.filter(|id| !candidates.contains(id)),
|
||||
)
|
||||
.map(|id| id.display_name().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The license menu: the curated SPDX list plus the free-text entry.
|
||||
fn license_menu() -> Vec<String> {
|
||||
KNOWN_LICENSES
|
||||
.iter()
|
||||
.copied()
|
||||
.map(str::to_string)
|
||||
.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) is preselected; anything else
|
||||
/// preselects the free-text entry prefilled with the probe. Without a probe
|
||||
/// the curated list defaults to MIT.
|
||||
fn license_question_default(probe_license: Option<&str>) -> (String, String) {
|
||||
match probe_license {
|
||||
Some(license) => match KNOWN_LICENSES
|
||||
.iter()
|
||||
.find(|k| k.eq_ignore_ascii_case(license))
|
||||
{
|
||||
Some(known) => ((*known).to_string(), 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(options: &[String], default: &str) -> Result<TemplateId, Box<dyn Error>> {
|
||||
loop {
|
||||
let answer = prompt::select(
|
||||
"Which language/build system is your program using?",
|
||||
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(
|
||||
label: &str,
|
||||
options: &[String],
|
||||
default: &str,
|
||||
accept: impl Fn(&str) -> bool,
|
||||
) -> Result<String, Box<dyn Error>> {
|
||||
loop {
|
||||
let answer = prompt::select(label, options, default)?;
|
||||
if accept(&answer) {
|
||||
return Ok(answer);
|
||||
}
|
||||
log::warn!("'{answer}' is not one of the offered answers; pick from the list");
|
||||
}
|
||||
}
|
||||
|
||||
/// One free-text question implementing the spec's "Enter accepts the
|
||||
/// default": an empty answer falls back to `default` (`Esc` keeps its
|
||||
/// prompt-level meaning of restoring the default). `validate` only ever
|
||||
/// sees non-empty answers — the empty one is accepted by the prompt loop so
|
||||
/// it can take the default path; callers that require an answer re-check
|
||||
/// the result.
|
||||
fn ask_text(
|
||||
label: &str,
|
||||
default: &str,
|
||||
validate: impl Fn(&str) -> Result<(), String> + 'static,
|
||||
) -> Result<String, Box<dyn Error>> {
|
||||
let accept_empty = move |answer: &str| {
|
||||
if answer.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
validate(answer)
|
||||
}
|
||||
};
|
||||
let answer = prompt::text(label, default, Some(&accept_empty))?;
|
||||
Ok(if answer.is_empty() {
|
||||
default.to_string()
|
||||
} else {
|
||||
answer
|
||||
})
|
||||
}
|
||||
|
||||
/// 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"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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 —
|
||||
/// a warning when dependencies cannot be vendored on this host.
|
||||
pub fn summary_text(opts: &NewOptions) -> 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 {
|
||||
lines
|
||||
.push(" debian/rules cargo build --release --offline (vendored)".to_string());
|
||||
} else {
|
||||
lines.push(format!(" debian/rules {}", template.rules_dh_line()));
|
||||
}
|
||||
}
|
||||
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(),
|
||||
native: false,
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_menu_and_defaults() {
|
||||
let menu = license_menu();
|
||||
assert_eq!(menu.len(), KNOWN_LICENSES.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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_screen_skeleton() {
|
||||
let text = summary_text(&opts(Tid::Makefile));
|
||||
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);
|
||||
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);
|
||||
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}");
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user