//! 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, contents: impl Into) -> OutputFile { OutputFile { path: path.into(), contents: contents.into(), executable: false, } } /// An executable file (mode 0755). pub fn executable(path: impl Into, contents: impl Into) -> 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, } /// 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, /// Project version. pub version: Option, /// Project description. pub description: Option, /// Project homepage. pub homepage: Option, /// Project license (SPDX identifier). pub license: Option, /// Installed command / binary name (e.g. the first `[[bin]]` target or /// console script). pub command: Option, /// 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, } /// 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; /// Extra `debian/` files beyond the common set rendered by /// [`super::debian`] (e.g. `debian/install`). fn debian(&self, opts: &NewOptions) -> Vec; /// Build-Depends beyond `debhelper-compat (= 13)`. fn build_depends(&self, _opts: &NewOptions) -> Vec { 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 { 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 { 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> { 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 { 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 { 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 $@"); } }