Files
pkh/src/new/templates/mod.rs
T

465 lines
17 KiB
Rust

//! 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, 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, PathBuf};
use super::options::{NewOptions, SourceDir, TemplateId};
/// One generated file, rendered in memory before anything touches the disk.
#[derive(Debug, Clone)]
pub struct OutputFile {
/// Path relative to the package tree root (e.g. `debian/control`).
pub path: String,
/// Full file contents.
pub contents: String,
/// Whether the file carries the executable bit (mode 0755).
pub executable: bool,
}
impl OutputFile {
/// A regular (non-executable) file.
pub fn new(path: impl Into<String>, contents: impl Into<String>) -> OutputFile {
OutputFile {
path: path.into(),
contents: contents.into(),
executable: false,
}
}
/// An executable file (mode 0755).
pub fn executable(path: impl Into<String>, contents: impl Into<String>) -> OutputFile {
OutputFile {
executable: true,
..OutputFile::new(path, contents)
}
}
}
/// What the template post-write hook did to the freshly written tree,
/// threaded through [`super::scaffold`] so the flow can react (e.g. word
/// the post-scaffold verification offer differently when vendoring failed).
#[derive(Debug, Clone, Default)]
pub struct ScaffoldOutcome {
/// The vendoring step did not complete (host `cargo` missing, `cargo
/// vendor` failed, or the offline config could not be written): the
/// package will not build until the dependencies are vendored manually.
pub vendoring_failed: bool,
/// How the orig tarball was actually created (quilt only; `None` with
/// the native format, which has no orig tarball). Filled in by the
/// scaffold flow, not by the template hook.
pub orig_origin: Option<String>,
}
/// Metadata extracted from an existing project by [`Template::probe`], used
/// by the interactive wizard to pre-fill its answers (explicit flags always
/// 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`).
pub name: Option<String>,
/// Project version.
pub version: Option<String>,
/// Project description.
pub description: Option<String>,
/// Project homepage.
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>,
/// Rust toolchain channel pinned by the project's `rust-toolchain.toml`
/// (or legacy `rust-toolchain`), e.g. `1.98.0`. Only set by the rust
/// template; the chroot build uses the distribution's rustc and ignores
/// the pin, so the wizard surfaces it as a heads-up instead.
pub toolchain_pin: Option<String>,
}
/// A package template: one supported ecosystem / build system.
///
/// `Sync` is required so templates can live in the static registry.
pub trait Template: Sync {
/// Identifier of this template.
fn id(&self) -> TemplateId;
/// Upstream-side files for the skeleton mode (e.g. `Cargo.toml`,
/// `src/main.rs`). Only called when packaging a fresh skeleton.
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile>;
/// Extra `debian/` files beyond the common set rendered by
/// [`super::debian`] (e.g. `debian/install`).
fn debian(&self, opts: &NewOptions) -> Vec<OutputFile>;
/// Build-Depends beyond `debhelper-compat (= 13)`.
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
Vec::new()
}
/// Architecture of the binary package (`all` or `any`).
fn architecture(&self, _opts: &NewOptions) -> &'static str {
"all"
}
/// 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()
}
/// Root `.gitignore` entries the template contributes in every mode
/// (the skeleton-mode build-artifact entries of
/// [`crate::new::debian::ROOT_GITIGNORE_ENTRIES`] are separate), merged
/// into the root `.gitignore` after the files are written — missing
/// ones appended, an existing file never overwritten. Default: none.
fn gitignore_entries(&self, _opts: &NewOptions) -> Vec<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). Returns the outcome
/// the flow should know about ([`ScaffoldOutcome`]); failures that leave
/// the tree in place but not buildable are reported through it instead
/// of failing the scaffold.
fn post_write(
&self,
_opts: &NewOptions,
_tree: &Path,
) -> Result<ScaffoldOutcome, Box<dyn std::error::Error>> {
Ok(ScaffoldOutcome::default())
}
}
/// 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.
static TEMPLATES: &[&dyn Template] = &[
&SHELL, &EMPTY, &MAKEFILE, &PYTHON, &MESON, &CMAKE, &AUTOTOOLS, &GO, &RUST,
];
/// 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::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),
}
}
/// Every implemented template.
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_every_template() {
for id in TemplateId::all() {
assert!(get(id).is_some(), "{id} must be registered");
assert_eq!(get(id).unwrap().id(), id);
}
assert_eq!(all().len(), TemplateId::all().len());
}
#[test]
fn probe_defaults_to_none() {
assert!(
get(TemplateId::Shell)
.unwrap()
.probe(Path::new("/"))
.is_none()
);
}
/// Only the rust template contributes root `.gitignore` entries of its
/// own — exactly the vendoring pair, and unconditionally (its vendoring
/// hook runs in every mode); every other template contributes nothing.
#[test]
fn gitignore_entries_are_rust_only() {
let o = NewOptions {
name: "mytool".into(),
template: TemplateId::Rust,
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(),
source_format: crate::new::options::SourceFormat::Quilt,
orig: None,
git: false,
autopkgtest: false,
pkg_config: false,
watch: None,
};
for id in TemplateId::all() {
let entries = get(id).unwrap().gitignore_entries(&o);
if id == TemplateId::Rust {
assert_eq!(
entries,
vec!["vendor/".to_string(), ".cargo/config.toml".to_string()]
);
} else {
assert!(entries.is_empty(), "{id} contributes {entries:?}");
}
}
}
/// 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(),
source_format: crate::new::options::SourceFormat::Quilt,
orig: None,
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(),
source_format: crate::new::options::SourceFormat::Quilt,
orig: None,
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 $@");
}
}