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
+261 -37
View File
@@ -1,16 +1,25 @@
//! Per-ecosystem template registry for `pkh new`.
//!
//! Every template implements [`Template`]: it renders the upstream-side
//! skeleton files, the extra `debian/` files beyond the common set, and (in
//! the future) probes an existing project for metadata. Rendering is plain
//! `format!` composition — no template engine, matching the codebase style.
//! skeleton files, the extra `debian/` files beyond the common set, probes an
//! existing project for metadata used to pre-fill the wizard answers, and
//! describes its Build-Depends / architecture / `debian/rules` shape.
//! Rendering is plain `format!` composition — no template engine, matching
//! the codebase style.
pub mod autotools;
pub mod cmake;
pub mod empty;
pub mod go;
pub mod makefile;
pub mod meson;
pub mod python;
pub mod rust;
pub mod shell;
use std::path::Path;
use std::path::{Path, PathBuf};
use super::options::{NewOptions, TemplateId};
use super::options::{NewOptions, SourceDir, TemplateId};
/// One generated file, rendered in memory before anything touches the disk.
#[derive(Debug, Clone)]
@@ -44,8 +53,8 @@ impl OutputFile {
/// Metadata extracted from an existing project by [`Template::probe`], used
/// by the interactive wizard to pre-fill its answers (explicit flags always
/// win). The per-template extraction is follow-up work; the hook already
/// exists so templates can grow it independently.
/// win). Every field is optional; probe failures are silent and the generic
/// defaults apply.
#[derive(Debug, Clone, Default)]
pub struct ProbeResult {
/// Project name (e.g. the `name` key of `Cargo.toml`).
@@ -58,6 +67,9 @@ pub struct ProbeResult {
pub homepage: Option<String>,
/// Project license (SPDX identifier).
pub license: Option<String>,
/// Installed command / binary name (e.g. the first `[[bin]]` target or
/// console script).
pub command: Option<String>,
}
/// A package template: one supported ecosystem / build system.
@@ -81,45 +93,87 @@ pub trait Template: Sync {
}
/// Architecture of the binary package (`all` or `any`).
fn architecture(&self) -> &'static str {
fn architecture(&self, _opts: &NewOptions) -> &'static str {
"all"
}
/// Lines appended to `debian/rules` after the default `dh $@` stanza.
fn rules_extra(&self) -> String {
/// The `dh` invocation (without leading tab) used by the `%:` target of
/// `debian/rules`. Templates needing more than the plain `dh $@` spell
/// their buildsystem/sequencer options here so the generated rules stay
/// valid make.
fn rules_dh_line(&self) -> String {
"dh $@".to_string()
}
/// Lines appended to `debian/rules` after the default `dh $@` stanza
/// (e.g. `override_dh_*` targets). Must use tabs for recipe lines.
fn rules_extra(&self, _opts: &NewOptions) -> String {
String::new()
}
/// Extra `debian/control` source-stanza fields beyond the common set
/// (e.g. `XS-Go-Import-Path`).
fn source_fields(&self, _opts: &NewOptions) -> Vec<(String, String)> {
Vec::new()
}
/// Extra defaults derived from project metadata in `dir` (detect.rs);
/// `None` when the project carries nothing this template can read.
fn probe(&self, _dir: &Path) -> Option<ProbeResult> {
None
}
/// Hook run after the generated files have been written to `tree` and
/// before the orig tarball is created, for templates that need to run
/// host tooling over the freshly written tree (e.g. `cargo vendor`, so
/// the vendored sources land inside the tarball).
fn post_write(
&self,
_opts: &NewOptions,
_tree: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
}
/// Static instance of the shell template.
pub static SHELL: shell::Shell = shell::Shell;
/// Static instance of the empty/metapackage template.
pub static EMPTY: empty::Empty = empty::Empty;
/// Static instance of the makefile template.
pub static MAKEFILE: makefile::Makefile = makefile::Makefile;
/// Static instance of the python template.
pub static PYTHON: python::Python = python::Python;
/// Static instance of the meson template.
pub static MESON: meson::Meson = meson::Meson;
/// Static instance of the cmake template.
pub static CMAKE: cmake::Cmake = cmake::Cmake;
/// Static instance of the autotools template.
pub static AUTOTOOLS: autotools::Autotools = autotools::Autotools;
/// Static instance of the go template.
pub static GO: go::Go = go::Go;
/// Static instance of the rust template.
pub static RUST: rust::Rust = rust::Rust;
/// Every implemented template (the wizard language menu lists
/// [`TemplateId::all()`] and greys the rest out).
static TEMPLATES: &[&dyn Template] = &[&SHELL, &EMPTY];
/// Every implemented template.
static TEMPLATES: &[&dyn Template] = &[
&SHELL, &EMPTY, &MAKEFILE, &PYTHON, &MESON, &CMAKE, &AUTOTOOLS, &GO, &RUST,
];
/// Look up the template implementation for `id`; `None` for the ids whose
/// template is not implemented yet (callers turn this into the friendly
/// "not implemented yet" error).
/// Look up the template implementation for `id`; `None` only if a
/// [`TemplateId`] ever grows without a registered template (callers turn
/// this into a friendly error instead of panicking).
pub fn get(id: TemplateId) -> Option<&'static dyn Template> {
match id {
TemplateId::Shell => Some(&SHELL),
TemplateId::Empty => Some(&EMPTY),
TemplateId::Rust
| TemplateId::Python
| TemplateId::Meson
| TemplateId::Cmake
| TemplateId::Autotools
| TemplateId::Go
| TemplateId::Makefile => None,
TemplateId::Makefile => Some(&MAKEFILE),
TemplateId::Python => Some(&PYTHON),
TemplateId::Meson => Some(&MESON),
TemplateId::Cmake => Some(&CMAKE),
TemplateId::Autotools => Some(&AUTOTOOLS),
TemplateId::Go => Some(&GO),
TemplateId::Rust => Some(&RUST),
}
}
@@ -128,28 +182,37 @@ pub fn all() -> &'static [&'static dyn Template] {
TEMPLATES
}
/// Locate `name` on `$PATH` (a tiny `which`): `None` when `PATH` is unset or
/// nothing executable-looking matches.
pub(crate) fn find_on_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(name))
.find(|candidate| candidate.is_file())
}
/// The directory whose sources are being packaged, when there is one:
/// `None` for the skeleton mode (the skeleton files are rendered in memory
/// and do not exist on disk yet).
pub(crate) fn source_dir_of(opts: &NewOptions) -> Option<PathBuf> {
match &opts.source_dir {
SourceDir::Skeleton => None,
SourceDir::Here => std::env::current_dir().ok(),
SourceDir::Path(path) => Some(path.clone()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_covers_implemented_templates() {
for id in [TemplateId::Shell, TemplateId::Empty] {
fn registry_covers_every_template() {
for id in TemplateId::all() {
assert!(get(id).is_some(), "{id} must be registered");
assert_eq!(get(id).unwrap().id(), id);
}
for id in [
TemplateId::Rust,
TemplateId::Python,
TemplateId::Meson,
TemplateId::Cmake,
TemplateId::Autotools,
TemplateId::Go,
TemplateId::Makefile,
] {
assert!(get(id).is_none(), "{id} must not pretend to be implemented");
}
assert_eq!(all().len(), 2);
assert_eq!(all().len(), TemplateId::all().len());
}
#[test]
@@ -161,4 +224,165 @@ mod tests {
.is_none()
);
}
/// The final `debian/rules` of every template must be valid-looking
/// make: `#!/usr/bin/make -f` shebang, exactly one `%:` target whose
/// recipe is the template's dh line, tab-indented recipes only, and no
/// trailing blank lines.
#[test]
fn rules_composition_per_template() {
let o = NewOptions {
name: "mytool".into(),
template: TemplateId::Empty,
source_dir: SourceDir::Here,
upstream_version: "0.1.0".into(),
revision: 1,
summary: "A tool".into(),
long_description: "A tool".into(),
homepage: None,
license: crate::new::options::License::Mit,
command: "mytool".into(),
maintainer: ("Jane".into(), "jane@example.com".into()),
dist: "ubuntu".into(),
series: "resolute".into(),
release: false,
depends: Vec::new(),
native: false,
git: false,
autopkgtest: false,
pkg_config: false,
watch: None,
};
for id in TemplateId::all() {
let template = get(id).unwrap();
let files = super::super::debian::files(&o, template);
let rules = files
.iter()
.find(|f| f.path == "debian/rules")
.expect("every template renders debian/rules");
assert!(rules.executable, "{id}: rules must carry the exec bit");
assert!(
rules.contents.starts_with("#!/usr/bin/make -f\n%:\n\t"),
"{id}: rules must start with the shebang and %: target"
);
assert!(
rules.contents.matches("\n%:\n").count() == 1,
"{id}: exactly one %: target expected"
);
// No recipe may be indented with spaces (make requires tabs).
for line in rules.contents.lines() {
assert!(
!line.starts_with(' '),
"{id}: space-indented line in rules: {line:?}"
);
}
// The template's dh line is the %: recipe.
assert!(
rules
.contents
.contains(&format!("\n%:\n\t{}\n", template.rules_dh_line())),
"{id}: %: recipe must be the dh line {:?} in {:?}",
template.rules_dh_line(),
rules.contents
);
// rule_extra targets must be declared at column 0 with tabbed
// recipes.
let extra = template.rules_extra(&o);
if !extra.is_empty() {
assert!(rules.contents.contains(&format!("\n{extra}")), "{id}");
}
}
}
/// Per-template Build-Depends / architecture / rules shape, locking the
/// table from the spec.
#[test]
fn build_depends_architecture_and_rules_table() {
let o = NewOptions {
name: "mytool".into(),
template: TemplateId::Empty,
source_dir: SourceDir::Skeleton,
upstream_version: "0.1.0".into(),
revision: 1,
summary: "A tool".into(),
long_description: "A tool".into(),
homepage: None,
license: crate::new::options::License::Mit,
command: "mytool".into(),
maintainer: ("Jane".into(), "jane@example.com".into()),
dist: "ubuntu".into(),
series: "resolute".into(),
release: false,
depends: Vec::new(),
native: false,
git: false,
autopkgtest: false,
pkg_config: false,
watch: None,
};
let deps = |id| {
let mut all = vec!["debhelper-compat (= 13)".to_string()];
all.extend(get(id).unwrap().build_depends(&o));
all.join(", ")
};
let arch = |id| get(id).unwrap().architecture(&o);
let dh = |id| get(id).unwrap().rules_dh_line();
assert_eq!(deps(TemplateId::Shell), "debhelper-compat (= 13)");
assert_eq!(arch(TemplateId::Shell), "all");
assert_eq!(dh(TemplateId::Shell), "dh $@");
assert_eq!(deps(TemplateId::Empty), "debhelper-compat (= 13)");
assert_eq!(arch(TemplateId::Empty), "all");
assert_eq!(
deps(TemplateId::Makefile),
"debhelper-compat (= 13), build-essential"
);
assert_eq!(arch(TemplateId::Makefile), "any");
assert_eq!(dh(TemplateId::Makefile), "dh $@");
assert_eq!(
dh(TemplateId::Python),
"dh $@ --with python3 --buildsystem=pybuild"
);
// Skeleton projects use the setuptools pyproject backend.
assert_eq!(
deps(TemplateId::Python),
"debhelper-compat (= 13), dh-python, python3-all, \
pybuild-plugin-pyproject, python3-setuptools"
);
assert_eq!(arch(TemplateId::Python), "all");
assert_eq!(deps(TemplateId::Meson), "debhelper-compat (= 13), meson");
assert_eq!(arch(TemplateId::Meson), "any");
assert_eq!(dh(TemplateId::Meson), "dh $@ --buildsystem=meson");
assert_eq!(deps(TemplateId::Cmake), "debhelper-compat (= 13), cmake");
assert_eq!(arch(TemplateId::Cmake), "any");
assert_eq!(dh(TemplateId::Cmake), "dh $@ --buildsystem=cmake");
assert_eq!(
deps(TemplateId::Autotools),
"debhelper-compat (= 13), autoconf, automake, libtool"
);
assert_eq!(arch(TemplateId::Autotools), "any");
assert_eq!(dh(TemplateId::Autotools), "dh $@");
assert_eq!(
deps(TemplateId::Go),
"debhelper-compat (= 13), golang-any, dh-golang"
);
assert_eq!(arch(TemplateId::Go), "any");
assert_eq!(dh(TemplateId::Go), "dh $@ --buildsystem=golang");
assert_eq!(
deps(TemplateId::Rust),
"debhelper-compat (= 13), cargo:native, rustc:native"
);
assert_eq!(arch(TemplateId::Rust), "any");
assert_eq!(dh(TemplateId::Rust), "dh $@");
}
}