new: add interactive wizard and remaining ecosystem templates

This commit is contained in:
2026-09-16 13:49:29 +02:00
parent d044f757e9
commit 9b98f5c7c3
17 changed files with 3930 additions and 99 deletions
+948
View File
@@ -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());
}
}