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
+13 -5
View File
@@ -234,13 +234,14 @@ fn main() {
match matches.subcommand() {
Some(("new", sub_matches)) => {
// Without the interactive wizard (follow-up work), missing
// required answers produce one error listing all of them;
// --defaults fills everything else from the defaults.
let depends: Vec<String> = sub_matches
.get_many::<String>("depends")
.map(|values| values.cloned().collect())
.unwrap_or_default();
let no_verify = sub_matches
.get_one::<bool>("no_verify")
.copied()
.unwrap_or(false);
let cli = pkh::new::options::NewCli {
name: sub_matches.get_one::<String>("name").cloned(),
lang: sub_matches.get_one::<String>("lang").cloned(),
@@ -275,9 +276,16 @@ fn main() {
.unwrap_or(false),
};
// The wizard (interactive terminal) fills the same NewCli and
// resolves through the same pipeline; without a TTY the resolve
// error lists every missing answer. Afterwards the two
// verification builds are offered (`--no-verify` skips them;
// the structural self-checks inside `scaffold` always run).
if let Err(e) = rt.block_on(async {
let opts = pkh::new::options::resolve(cli).await?;
pkh::new::scaffold(opts, &multi)
let opts = pkh::new::questions::run(cli).await?;
pkh::new::scaffold(opts.clone(), &multi)?;
pkh::new::questions::offer_verification(&opts, &multi, no_verify).await;
Ok::<(), Box<dyn std::error::Error>>(())
}) {
error!("{}", e);
std::process::exit(1);
+90 -8
View File
@@ -50,16 +50,45 @@ pub fn files(opts: &NewOptions, template: &dyn Template) -> Vec<OutputFile> {
source_format(opts),
changelog(opts),
control(opts, template),
rules(template),
rules(opts, template),
copyright(opts),
debian_gitignore(opts),
];
if !opts.native {
files.push(local_options());
}
if opts.autopkgtest {
files.push(autopkgtest_control());
files.push(autopkgtest_smoke(opts));
}
if let Some(watch) = &opts.watch {
files.push(OutputFile::new("debian/watch", watch.clone()));
}
files
}
/// `debian/tests/control`: the autopkgtest smoke test definition.
fn autopkgtest_control() -> OutputFile {
OutputFile::new(
"debian/tests/control",
"Tests: smoke\nDepends: @\nRestrictions: allow-stderr\n",
)
}
/// `debian/tests/smoke`: run the installed command once; `--help` first,
/// `--version` as the fallback (some tools only answer one of them).
fn autopkgtest_smoke(opts: &NewOptions) -> OutputFile {
OutputFile::executable(
"debian/tests/smoke",
format!(
"#!/bin/sh\n\
set -e\n\
{command} --help >/dev/null 2>&1 || {command} --version\n",
command = opts.command,
),
)
}
/// `debian/source/format`: `3.0 (quilt)` by default, `3.0 (native)` with
/// `--native`.
fn source_format(opts: &NewOptions) -> OutputFile {
@@ -173,6 +202,10 @@ fn control(opts: &NewOptions, template: &dyn Template) -> OutputFile {
build_depends.extend(template.build_depends(opts));
control.push_str(&render_field("Build-Depends", &build_depends));
for (key, value) in template.source_fields(opts) {
control.push_str(&format!("{key}: {value}\n"));
}
if let Some(homepage) = &opts.homepage {
control.push_str(&format!("Homepage: {homepage}\n"));
}
@@ -181,7 +214,7 @@ fn control(opts: &NewOptions, template: &dyn Template) -> OutputFile {
// Binary stanza.
control.push_str(&format!("Package: {}\n", opts.name));
control.push_str(&format!("Architecture: {}\n", template.architecture()));
control.push_str(&format!("Architecture: {}\n", template.architecture(opts)));
if !opts.depends.is_empty() {
control.push_str(&render_field("Depends", &opts.depends));
}
@@ -191,11 +224,12 @@ fn control(opts: &NewOptions, template: &dyn Template) -> OutputFile {
OutputFile::new("debian/control", control)
}
/// `debian/rules`: the minimal `dh $@` makefile (plus the template's extra
/// overrides, when any), written with the executable bit.
fn rules(template: &dyn Template) -> OutputFile {
let mut contents = String::from("#!/usr/bin/make -f\n%:\n\tdh $@\n");
let extra = template.rules_extra();
/// `debian/rules`: the shebang and `%:` target whose recipe is the
/// template's dh line (plus the template's extra overrides, when any),
/// written with the executable bit.
fn rules(opts: &NewOptions, template: &dyn Template) -> OutputFile {
let mut contents = format!("#!/usr/bin/make -f\n%:\n\t{}\n", template.rules_dh_line());
let extra = template.rules_extra(opts);
if !extra.is_empty() {
contents.push('\n');
contents.push_str(&extra);
@@ -464,6 +498,9 @@ mod tests {
depends: Vec::new(),
native: false,
git: true,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
@@ -591,11 +628,56 @@ mod tests {
#[test]
fn rules_is_executable_minimal_makefile() {
let rules = super::rules(crate::new::templates::get(TemplateId::Shell).unwrap());
let o = opts();
let rules = super::rules(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
assert!(rules.executable);
assert_eq!(rules.contents, "#!/usr/bin/make -f\n%:\n\tdh $@\n");
}
#[test]
fn extra_files_autopkgtest_and_watch() {
let mut o = opts();
o.autopkgtest = true;
o.watch = Some(
"version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
.to_string(),
);
let files = super::files(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
let find = |path: &str| {
files
.iter()
.find(|f| f.path == path)
.unwrap_or_else(|| panic!("{path} missing"))
};
let control = find("debian/tests/control");
assert_eq!(
control.contents,
"Tests: smoke\nDepends: @\nRestrictions: allow-stderr\n"
);
let smoke = find("debian/tests/smoke");
assert!(smoke.executable);
assert!(smoke.contents.starts_with("#!/bin/sh\nset -e\n"));
assert!(
smoke
.contents
.contains("mytool --help >/dev/null 2>&1 || mytool --version")
);
assert_eq!(
find("debian/watch").contents,
"version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
);
// Without the extras none of the files are rendered.
let plain = super::files(
&opts(),
crate::new::templates::get(TemplateId::Shell).unwrap(),
);
assert!(!plain.iter().any(|f| f.path.starts_with("debian/tests")));
assert!(!plain.iter().any(|f| f.path == "debian/watch"));
}
#[test]
fn copyright_is_dep5() {
let c = super::copyright(&opts());
+192 -1
View File
@@ -21,6 +21,8 @@
use std::path::Path;
use regex::Regex;
use super::options::TemplateId;
/// Outcome of the detection.
@@ -74,7 +76,7 @@ pub fn detect(dir: &Path) -> Detection {
/// The single top-level script of `dir`, if there is exactly one: a file
/// with the `.sh` extension, or whose first line starts with `#!`. Returns
/// `None` when there are zero or several candidates.
fn single_script(dir: &Path) -> Option<std::path::PathBuf> {
pub fn single_script(dir: &Path) -> Option<std::path::PathBuf> {
let mut found: Option<std::path::PathBuf> = None;
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
@@ -107,6 +109,102 @@ fn has_shebang(path: &Path) -> bool {
content.starts_with(b"#!")
}
/// License files looked at by [`sniff_license`], in preference order.
const LICENSE_FILES: [&str; 5] = [
"LICENSE",
"LICENSE.md",
"LICENSE.txt",
"COPYING",
"COPYING.txt",
];
/// Sniff the license of the project in `dir` from its `LICENSE`/`COPYING`
/// file: an `SPDX-License-Identifier:` line wins, otherwise the text is
/// matched against a short list of recognizable licenses (MIT, BSD-2/3,
/// Apache-2.0, GPL-2/3, LGPL-2.1/3, ISC). `None` when no license file
/// exists or nothing recognizable is found.
pub fn sniff_license(dir: &Path) -> Option<String> {
let content = LICENSE_FILES
.iter()
.find_map(|name| std::fs::read_to_string(dir.join(name)).ok())
// Case variants and suffixes (LICENSE-MIT, LICENCE, cpYING…): the
// first top-level file whose name looks like a license notice.
.or_else(|| {
let mut candidates: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
.ok()?
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.is_file()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
let name = name.to_ascii_uppercase();
// American and British spellings both count.
name.starts_with("LICENSE")
|| name.starts_with("LICENCE")
|| name.starts_with("COPYING")
})
})
.collect();
candidates.sort();
std::fs::read_to_string(candidates.into_iter().next()?).ok()
})?;
// An explicit SPDX identifier is the most reliable signal.
static SPDX_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let spdx = SPDX_REGEX.get_or_init(|| {
Regex::new(r"(?i)SPDX-License-Identifier\s*:\s*([A-Za-z0-9+.\- ]+)").unwrap()
});
if let Some(id) = spdx
.captures(&content)
.and_then(|caps| caps.get(1))
.map(|id| id.as_str().trim_end().to_string())
.filter(|id| !id.is_empty())
{
return Some(id);
}
let text = content.to_ascii_lowercase();
if text.contains("apache license") && text.contains("version 2") {
return Some("Apache-2.0".to_string());
}
if text.contains("lesser general public license") {
return if text.contains("version 3") && !text.contains("version 2.1") {
Some("LGPL-3.0+".to_string())
} else {
Some("LGPL-2.1+".to_string())
};
}
if text.contains("general public license") {
return if text.contains("version 3") {
Some("GPL-3.0+".to_string())
} else {
Some("GPL-2.0+".to_string())
};
}
if text.contains("mit license") || text.contains("permission is hereby granted, free of charge")
{
return Some("MIT".to_string());
}
if text.contains("isc license")
|| text.contains("permission to use, copy, modify, and/or distribute this software")
{
return Some("ISC".to_string());
}
if text.contains("redistribution and use in source and binary forms") {
// The third clause (name endorsement) is what sets BSD-3 apart
// from BSD-2.
return if text.contains("endorse or promote") {
Some("BSD-3-Clause".to_string())
} else {
Some("BSD-2-Clause".to_string())
};
}
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -203,4 +301,97 @@ mod tests {
// The only "real" script candidate is in a subdir or hidden: no hit.
assert_eq!(detect(dir.path()), Detection::Empty);
}
/// Distinctive (shortened) excerpts of the recognizable license texts.
const LICENSE_TEXTS: [(&str, &str); 9] = [
(
"MIT",
"MIT License\n\nPermission is hereby granted, free of charge, to any person",
),
("Apache-2.0", "Apache License\nVersion 2.0, January 2004"),
(
"GPL-2.0+",
"GNU GENERAL PUBLIC LICENSE\nVersion 2, June 1991\neither version 2 of the License",
),
(
"GPL-3.0+",
"GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
),
(
"LGPL-2.1+",
"GNU LESSER GENERAL PUBLIC LICENSE\nVersion 2.1, February 1999",
),
(
"LGPL-3.0+",
"GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
),
(
"BSD-2-Clause",
"Redistribution and use in source and binary forms, with or without\nmodification, are permitted",
),
(
"BSD-3-Clause",
"Redistribution and use in source and binary forms, with or without\nmay be used to endorse or promote products",
),
(
"ISC",
"ISC License\nPermission to use, copy, modify, and/or distribute this software",
),
];
#[test]
fn sniff_license_recognizes_license_files() {
for (expected, text) in LICENSE_TEXTS {
// Every candidate file name is looked at.
for name in ["LICENSE", "COPYING", "LICENSE.md", "COPYING.txt"] {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(name), text).unwrap();
assert_eq!(
sniff_license(dir.path()).as_deref(),
Some(expected),
"{name}: {expected}"
);
}
}
}
#[test]
fn sniff_license_prefers_spdx_identifier() {
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("LICENSE"),
"Custom terms here\nSPDX-License-Identifier: Zlib\n",
)
.unwrap();
assert_eq!(sniff_license(dir.path()).as_deref(), Some("Zlib"));
}
#[test]
fn sniff_license_handles_case_variants_and_missing_files() {
// Unusual spelling found through the directory scan.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("Licence.TXT"),
"Permission is hereby granted, free of charge",
)
.unwrap();
assert_eq!(sniff_license(dir.path()).as_deref(), Some("MIT"));
// Exact candidates win over the directory scan (LICENSE before
// LICENSE.blurb).
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("LICENSE.blurb"),
"GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
)
.unwrap();
std::fs::write(dir.path().join("LICENSE"), "MIT License").unwrap();
assert_eq!(sniff_license(dir.path()).as_deref(), Some("MIT"));
// Unrecognizable or missing text: silent None.
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("LICENSE"), "do whatever you want\n").unwrap();
assert_eq!(sniff_license(dir.path()), None);
assert_eq!(sniff_license(&dir.path().join("missing")), None);
}
}
+158 -31
View File
@@ -2,17 +2,19 @@
//! `plans/pkh-new.md`).
//!
//! This module orchestrates a scaffold run: target directory checks, project
//! detection, in-memory rendering of every file (all-or-nothing write), orig
//! tarball creation, git initialization, structural verification and the
//! next-steps message. The interactive wizard and the remaining templates
//! (python, meson, cmake, autotools, go, rust, makefile) are follow-up work
//! built on top of the same [`options::NewOptions`] and
//! [`templates::Template`] surface.
//! detection, in-memory rendering of every file (all-or-nothing write), the
//! template post-write hook (e.g. `cargo vendor`), orig tarball creation,
//! git initialization, structural verification and the next-steps message.
//! The interactive wizard ([`questions`]) fills a [`options::NewCli`] from
//! its answers on a TTY and reuses [`options::resolve`] as the single source
//! of truth for defaults and validation; without a TTY the same resolution
//! runs flag-driven.
pub mod debian;
pub mod detect;
pub mod git;
pub mod options;
pub mod questions;
pub mod templates;
pub mod verify;
@@ -27,15 +29,17 @@ use templates::OutputFile;
/// Scaffold a full Debian source tree from `opts`.
///
/// Steps, aborting early with a pointed error message:
/// 1. resolve the template (unimplemented ids fail here, not at parse time),
/// 1. resolve the template from the registry,
/// 2. check the target directory (refuse an existing `debian/control`),
/// 3. render every file in memory and check for collisions,
/// 4. write the files all-or-nothing (plus the root `.gitignore` in skeleton
/// mode, appending to an existing one),
/// 5. create the orig tarball (quilt only, refusing overwrites),
/// 6. `git init` unless `--no-git` or already inside a repository,
/// 7. run the structural verification,
/// 8. print the success message with the next steps.
/// 5. run the template post-write hook (e.g. `cargo vendor`, so the vendored
/// sources land inside the orig tarball created next),
/// 6. create the orig tarball (quilt only, refusing overwrites),
/// 7. `git init` unless `--no-git` or already inside a repository,
/// 8. run the structural verification,
/// 9. print the success message with the next steps.
pub fn scaffold(opts: NewOptions, multi: &MultiProgress) -> Result<(), Box<dyn Error>> {
let pb = multi.add(ProgressBar::new_spinner());
pb.enable_steady_tick(Duration::from_millis(50));
@@ -62,17 +66,13 @@ pub fn scaffold(opts: NewOptions, multi: &MultiProgress) -> Result<(), Box<dyn E
/// The scaffold steps proper, reporting progress through `pb`. Nothing is
/// written to the filesystem before every file rendered successfully.
fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result<(), Box<dyn Error>> {
// 1. Template resolution: unknown-but-valid ids fail here with the
// friendly message instead of a parse error.
// 1. Template resolution: an id without a registered template fails
// here with the friendly message instead of a parse error.
let template = templates::get(opts.template).ok_or_else(|| {
format!(
"The '{}' template is not implemented yet. Implemented templates: {}.",
"The '{}' template has no registered implementation. \
This is a pkh bug; please report it.",
opts.template,
templates::all()
.iter()
.map(|t| t.id().as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
@@ -173,17 +173,23 @@ fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result<(), Box<dyn Err
std::fs::write(target.join(".gitignore"), contents)?;
}
// 5. Orig tarball (quilt only).
// 5. Template post-write hook: run before the orig tarball is created,
// so files added here (rust: vendor/ + .cargo/config.toml) land
// inside it.
pb.set_message("Running template hooks");
template.post_write(opts, &target)?;
// 6. Orig tarball (quilt only).
if !opts.native {
pb.set_message("Creating orig tarball");
debian::create_orig_tarball(&target, &opts.name, &opts.upstream_version_no_epoch())?;
}
// 6. Git.
// 7. Git.
pb.set_message("Initializing git");
git::ensure_repository(&target, opts.git)?;
// 7. Structural verification.
// 8. Structural verification.
pb.set_message("Verifying");
verify::verify(&target)?;
@@ -250,6 +256,9 @@ mod tests {
depends: Vec::new(),
native: false,
git: false,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
@@ -527,15 +536,6 @@ mod tests {
)
.unwrap_err();
assert!(err.to_string().contains("does not exist"), "{err}");
// Unimplemented template.
let dir = tempdir().unwrap();
let err = scaffold_in(
dir.path(),
opts(TemplateId::Rust, "mytool", SourceDir::Skeleton),
)
.unwrap_err();
assert!(err.to_string().contains("not implemented yet"), "{err}");
}
#[test]
@@ -555,6 +555,133 @@ mod tests {
);
}
/// End-to-end rust skeleton: the vendoring hook runs before the orig
/// tarball is created, so `.cargo/` (and `vendor/` when dependencies
/// exist) travel inside it. The vendoring step needs host cargo; on a
/// cargo-less host the scaffold still succeeds with a warning.
#[test]
#[serial]
fn scaffold_rust_skeleton_vendors_before_tarball() {
let dir = tempdir().unwrap();
scaffold_in(
dir.path(),
opts(TemplateId::Rust, "mytool", SourceDir::Skeleton),
)
.unwrap();
let tree = dir.path().join("mytool");
assert!(tree.join("Cargo.toml").exists());
assert!(tree.join("src/main.rs").exists());
// rules: the vendored build overrides, no --locked on a fresh
// skeleton without Cargo.lock.
let rules = std::fs::read_to_string(tree.join("debian/rules")).unwrap();
assert!(rules.contains("%:\n\tdh $@\n"));
assert!(rules.contains("override_dh_auto_build:\n\tcargo build --release --offline\n"));
assert!(rules.contains("override_dh_auto_install:\n\tinstall -Dm755 target/release/mytool debian/mytool/usr/bin/mytool"));
assert!(!rules.contains("--locked"));
// control: Architecture any + the cargo/rustc build-deps.
let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap();
assert_eq!(control.binaries[0].get("Architecture"), Some("any"));
assert_eq!(
control.source.get("Build-Depends"),
Some("debhelper-compat (= 13),\ncargo:native,\nrustc:native")
);
// The offline config exists when host cargo vendored the skeleton,
// and both it and the skeleton land inside the orig tarball.
let has_cargo = crate::new::templates::find_on_path("cargo").is_some();
if has_cargo {
let config = std::fs::read_to_string(tree.join(".cargo/config.toml")).unwrap();
assert!(config.contains("[source.crates-io]"), "{config}");
assert!(config.contains("[net]\noffline = true"), "{config}");
}
let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz");
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
std::fs::File::open(&tarball).unwrap(),
));
let names: Vec<String> = archive
.entries()
.unwrap()
.map(|entry| {
entry
.unwrap()
.path()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect();
assert!(
names.iter().any(|n| n == "mytool-0.1.0/Cargo.toml"),
"{names:?}"
);
assert!(
names.iter().any(|n| n == "mytool-0.1.0/src/main.rs"),
"{names:?}"
);
if has_cargo {
assert!(
names.iter().any(|n| n == "mytool-0.1.0/.cargo/config.toml"),
"{names:?}"
);
}
assert!(!names.iter().any(|n| n.contains("debian")), "{names:?}");
}
/// End-to-end python skeleton: pyproject-based Build-Depends and the
/// module skeleton inside the orig tarball.
#[test]
#[serial]
fn scaffold_python_skeleton_tree() {
let dir = tempdir().unwrap();
scaffold_in(
dir.path(),
opts(TemplateId::Python, "mytool", SourceDir::Skeleton),
)
.unwrap();
let tree = dir.path().join("mytool");
let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap();
assert_eq!(control.binaries[0].get("Architecture"), Some("all"));
assert_eq!(
control.source.get("Build-Depends"),
Some(
"debhelper-compat (= 13),\ndh-python,\npython3-all,\n\
pybuild-plugin-pyproject,\npython3-setuptools"
)
);
let rules = std::fs::read_to_string(tree.join("debian/rules")).unwrap();
assert!(rules.contains("%:\n\tdh $@ --with python3 --buildsystem=pybuild\n"));
let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz");
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
std::fs::File::open(&tarball).unwrap(),
));
let names: Vec<String> = archive
.entries()
.unwrap()
.map(|entry| {
entry
.unwrap()
.path()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect();
assert!(
names.iter().any(|n| n == "mytool-0.1.0/pyproject.toml"),
"{names:?}"
);
assert!(
names.iter().any(|n| n == "mytool-0.1.0/mytool/__init__.py"),
"{names:?}"
);
}
/// End-to-end: the scaffolded shell tree passes the real source build
/// (`dpkg-source` and friends, same prerequisites as the differential
/// tests).
+51 -12
View File
@@ -19,9 +19,8 @@ use crate::new::detect::{self, Detection};
/// Build systems / project kinds `pkh new` knows about.
///
/// The identifiers are stable CLI surface: `--lang` accepts every variant,
/// even the ones that have no template implementation yet (those fail at
/// scaffold time with a "not implemented yet" error instead of a parse
/// error, so scripts written today keep working once they land).
/// and every variant has a template implementation registered in
/// [`crate::new::templates`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TemplateId {
/// Rust project (`Cargo.toml`)
@@ -93,10 +92,27 @@ impl TemplateId {
})
}
/// Whether a template implementation (and therefore scaffolding) exists
/// for this id. The remaining templates land with the follow-up work.
pub fn implemented(&self) -> bool {
matches!(self, TemplateId::Shell | TemplateId::Empty)
/// Human-readable menu label of this template, as offered by the wizard
/// language question (and reused in the summary screen).
pub fn display_name(&self) -> &'static str {
match self {
TemplateId::Rust => "Rust (Cargo.toml)",
TemplateId::Python => "Python (pyproject.toml / setup.py)",
TemplateId::Meson => "C/C++ (Meson)",
TemplateId::Cmake => "C/C++ (CMake)",
TemplateId::Autotools => "C/C++ (Autotools)",
TemplateId::Go => "Go module",
TemplateId::Shell => "Shell script / single interpreted file",
TemplateId::Makefile => "Generic (Makefile)",
TemplateId::Empty => "Metapackage / empty base (no build system)",
}
}
/// The template whose menu label (or CLI identifier) is `label`.
pub fn from_label(label: &str) -> Option<TemplateId> {
TemplateId::all()
.into_iter()
.find(|id| id.display_name() == label || id.as_str() == label)
}
}
@@ -230,6 +246,17 @@ pub struct NewOptions {
pub native: bool,
/// Initialize a git repository (gitignores are written regardless).
pub git: bool,
/// Write the autopkgtest smoke test (`debian/tests/control` +
/// `debian/tests/smoke`) running `<command> --help`/`--version`.
/// Wizard-only extra, off by default.
pub autopkgtest: bool,
/// Add `pkg-config` to Build-Depends (the meson/cmake opt-in question;
/// most such projects resolve their dependencies through it).
/// Wizard-only extra, off by default.
pub pkg_config: bool,
/// Contents of a `debian/watch` release watcher (GitHub/GitLab tarball
/// template). Wizard-only extra, off by default.
pub watch: Option<String>,
}
impl NewOptions {
@@ -568,7 +595,8 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
if !missing.is_empty() {
return Err(format!(
"Missing required answers (use --defaults to take every default, \
or answer interactively once the wizard lands):\n - {}",
or re-run pkh new on an interactive terminal to answer the \
wizard):\n - {}",
missing.join("\n - ")
));
}
@@ -647,6 +675,9 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
depends,
native: cli.native,
git: cli.git,
autopkgtest: false,
pkg_config: false,
watch: None,
})
}
@@ -671,12 +702,17 @@ mod tests {
fn template_ids_roundtrip() {
for id in TemplateId::all() {
assert_eq!(TemplateId::parse(id.as_str()).unwrap(), id);
// Every id resolves from its menu label too, and labels are
// unique.
assert_eq!(TemplateId::from_label(id.display_name()), Some(id));
}
assert!(TemplateId::parse("cobol").is_err());
// Every id is accepted by the parser; only some are implemented.
assert!(!TemplateId::Rust.implemented());
assert!(TemplateId::Shell.implemented());
assert!(TemplateId::Empty.implemented());
assert_eq!(
TemplateId::from_label("Rust (Cargo.toml)"),
Some(TemplateId::Rust)
);
assert_eq!(TemplateId::from_label("rust"), Some(TemplateId::Rust));
assert_eq!(TemplateId::from_label("nope"), None);
}
#[test]
@@ -754,6 +790,9 @@ mod tests {
depends: Vec::new(),
native: false,
git: false,
autopkgtest: false,
pkg_config: false,
watch: None,
};
assert_eq!(opts.full_version(), "0.1.0-1");
assert_eq!(
+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 23),
/// 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());
}
}
+249
View File
@@ -0,0 +1,249 @@
//! The `autotools` template: a C project with a `configure.ac` built through
//! debhelper's auto-detection (dh runs `autoreconf` itself when it finds
//! `configure.ac`, debhelper ≥ 10 — no override needed).
use std::path::Path;
use regex::Regex;
use super::meson::hello_c;
use super::{OutputFile, ProbeResult, Template, source_dir_of};
use crate::new::options::{NewOptions, TemplateId};
/// C/C++ with Autotools (`configure.ac`).
pub struct Autotools;
impl Template for Autotools {
fn id(&self) -> TemplateId {
TemplateId::Autotools
}
/// A minimal `configure.ac`, the matching `Makefile.am` and `hello.c`.
/// The first source build runs `autoreconf` (integrated in the dh
/// sequence), so no generated configure script is committed.
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
vec![
OutputFile::new(
"configure.ac",
format!(
"AC_INIT([{name}], [{version}])\n\
AM_INIT_AUTOMAKE([foreign])\n\
AC_PROG_CC\n\
AC_CONFIG_FILES([Makefile])\n\
AC_OUTPUT\n",
name = opts.name,
version = opts.upstream_version,
),
),
OutputFile::new(
"Makefile.am",
format!(
"bin_PROGRAMS = {command}\n\
{command}_SOURCES = hello.c\n",
command = opts.command,
),
),
hello_c(opts),
]
}
/// No extra debian/ files: plain `dh $@` auto-detects `configure.ac`.
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
Vec::new()
}
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
let mut deps = vec![
"autoconf".to_string(),
"automake".to_string(),
"libtool".to_string(),
];
if uses_gettext(opts) {
deps.push("gettext".to_string());
}
deps
}
fn architecture(&self, _opts: &NewOptions) -> &'static str {
"any"
}
/// Package name and version from the `AC_INIT` macro of `configure.ac`.
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
let content = std::fs::read_to_string(dir.join("configure.ac")).ok()?;
static AC_INIT_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
// AC_INIT([name], [version]) — the autoconf quotes are optional.
let regex = AC_INIT_REGEX.get_or_init(|| {
Regex::new(
r"AC_INIT\s*\(\s*(?:\[([^\]]*)\]|([^,\s\[]+))\s*,\s*(?:\[([^\]]*)\]|([^,\s\[]+))",
)
.unwrap()
});
let caps = regex.captures(&content)?;
let value = |bracketed: usize, bare: usize| {
caps.get(bracketed)
.or_else(|| caps.get(bare))
.map(|v| v.as_str().trim().to_string())
.filter(|v| !v.is_empty())
};
let name = value(1, 2)?;
let version = value(3, 4);
Some(ProbeResult {
name: Some(name),
version,
..Default::default()
})
}
}
/// Whether the packaged `configure.ac` sets up GNU gettext (`AM_GNU_GETTEXT`
/// macro): those builds need the `gettext` package. Only meaningful when
/// packaging an existing tree (the generated skeleton carries no gettext).
fn uses_gettext(opts: &NewOptions) -> bool {
source_dir_of(opts).is_some_and(|dir| {
std::fs::read_to_string(dir.join("configure.ac"))
.is_ok_and(|content| content.contains("AM_GNU_GETTEXT"))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new::options::{License, SourceDir};
use tempfile::tempdir;
fn opts() -> NewOptions {
NewOptions {
name: "mytool".into(),
template: TemplateId::Autotools,
source_dir: SourceDir::Skeleton,
upstream_version: "0.1.0".into(),
revision: 1,
summary: "A tool".into(),
long_description: "A tool".into(),
homepage: None,
license: 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: true,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
#[test]
fn autotools_template_shape() {
let o = opts();
let template = super::super::get(TemplateId::Autotools).unwrap();
assert_eq!(template.architecture(&o), "any");
assert_eq!(
template.build_depends(&o),
vec![
"autoconf".to_string(),
"automake".to_string(),
"libtool".to_string()
]
);
assert_eq!(template.rules_dh_line(), "dh $@");
assert!(template.rules_extra(&o).is_empty());
assert!(template.debian(&o).is_empty());
let skeleton = template.skeleton(&o);
let configure = skeleton
.iter()
.find(|f| f.path == "configure.ac")
.expect("configure.ac skeleton");
assert!(
configure
.contents
.starts_with("AC_INIT([mytool], [0.1.0])\n")
);
let makefile_am = skeleton
.iter()
.find(|f| f.path == "Makefile.am")
.expect("Makefile.am skeleton");
assert!(makefile_am.contents.contains("bin_PROGRAMS = mytool"));
assert!(makefile_am.contents.contains("mytool_SOURCES = hello.c"));
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
}
#[test]
fn autotools_probe_reads_ac_init() {
let template = super::super::get(TemplateId::Autotools).unwrap();
// Bracketed form (the generated skeleton's own shape).
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("configure.ac"),
"AC_INIT([mytool], [0.1.0])\nAM_INIT_AUTOMAKE([foreign])\n",
)
.unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("mytool"));
assert_eq!(probe.version.as_deref(), Some("0.1.0"));
// Bare form with a bug-report address as the third argument.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("configure.ac"),
"AC_INIT(mytool, 1.2.3, bugs@example.com)\n",
)
.unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("mytool"));
assert_eq!(probe.version.as_deref(), Some("1.2.3"));
// Name with spaces inside the brackets.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("configure.ac"),
"AC_INIT([My Tool], [2.0])\n",
)
.unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("My Tool"));
// No configure.ac: silent None.
let dir = tempdir().unwrap();
assert!(template.probe(dir.path()).is_none());
}
#[test]
fn gettext_detected_in_configure_ac() {
// Skeleton: the generated configure.ac has no gettext.
let o = opts();
assert!(!uses_gettext(&o));
assert!(
!super::super::get(TemplateId::Autotools)
.unwrap()
.build_depends(&o)
.contains(&"gettext".to_string())
);
// Existing tree with AM_GNU_GETTEXT: gettext joins Build-Depends.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("configure.ac"),
"AC_INIT([mytool], [0.1.0])\nAM_GNU_GETTEXT([external])\n",
)
.unwrap();
let o = NewOptions {
source_dir: SourceDir::Path(dir.path().to_path_buf()),
..opts()
};
assert!(uses_gettext(&o));
assert!(
super::super::get(TemplateId::Autotools)
.unwrap()
.build_depends(&o)
.contains(&"gettext".to_string())
);
}
}
+175
View File
@@ -0,0 +1,175 @@
//! The `cmake` template: a C/C++ project built with CMake through the
//! debhelper cmake buildsystem.
use std::path::Path;
use regex::Regex;
use super::meson::hello_c;
use super::{OutputFile, ProbeResult, Template};
use crate::new::options::{NewOptions, TemplateId};
/// C/C++ with CMake (`CMakeLists.txt`).
pub struct Cmake;
impl Template for Cmake {
fn id(&self) -> TemplateId {
TemplateId::Cmake
}
/// A minimal `CMakeLists.txt` (project declaration + one installed
/// executable) and the classic `hello.c`.
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
vec![
OutputFile::new(
"CMakeLists.txt",
format!(
"cmake_minimum_required(VERSION 3.16)\n\
project({name} VERSION {version})\n\
\n\
add_executable({command} hello.c)\n\
install(TARGETS {command} RUNTIME DESTINATION bin)\n",
name = opts.name,
version = opts.upstream_version,
command = opts.command,
),
),
hello_c(opts),
]
}
/// No extra debian/ files: debhelper's cmake buildsystem handles the
/// configure/build/install steps.
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
Vec::new()
}
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
let mut deps = vec!["cmake".to_string()];
if opts.pkg_config {
deps.push("pkg-config".to_string());
}
deps
}
fn architecture(&self, _opts: &NewOptions) -> &'static str {
"any"
}
fn rules_dh_line(&self) -> String {
"dh $@ --buildsystem=cmake".to_string()
}
/// Project name and version from the `project(<name> VERSION …)`
/// declaration.
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
let content = std::fs::read_to_string(dir.join("CMakeLists.txt")).ok()?;
static PROJECT_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let regex = PROJECT_REGEX.get_or_init(|| {
Regex::new(r"(?im)^\s*project\s*\(\s*([A-Za-z0-9_][A-Za-z0-9_.\-]*)(?:\s+VERSION\s+([0-9][^\s)]*))?").unwrap()
});
let caps = regex.captures(&content)?;
let version = caps
.get(2)
.map(|v| v.as_str().trim_end_matches('.').to_string())
.filter(|v| !v.is_empty());
Some(ProbeResult {
name: Some(caps[1].to_string()),
version,
..Default::default()
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new::options::{License, SourceDir};
use tempfile::tempdir;
fn opts() -> NewOptions {
NewOptions {
name: "mytool".into(),
template: TemplateId::Cmake,
source_dir: SourceDir::Skeleton,
upstream_version: "0.1.0".into(),
revision: 1,
summary: "A tool".into(),
long_description: "A tool".into(),
homepage: None,
license: 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: true,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
#[test]
fn cmake_template_shape() {
let o = opts();
let template = super::super::get(TemplateId::Cmake).unwrap();
assert_eq!(template.architecture(&o), "any");
assert_eq!(template.build_depends(&o), vec!["cmake".to_string()]);
assert_eq!(template.rules_dh_line(), "dh $@ --buildsystem=cmake");
assert!(template.rules_extra(&o).is_empty());
assert!(template.debian(&o).is_empty());
let skeleton = template.skeleton(&o);
let cmakelists = skeleton
.iter()
.find(|f| f.path == "CMakeLists.txt")
.expect("CMakeLists.txt skeleton");
assert!(
cmakelists
.contents
.contains("project(mytool VERSION 0.1.0)")
);
assert!(
cmakelists
.contents
.contains("add_executable(mytool hello.c)")
);
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
}
#[test]
fn cmake_probe_reads_project_declaration() {
let template = super::super::get(TemplateId::Cmake).unwrap();
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("CMakeLists.txt"),
"cmake_minimum_required(VERSION 3.16)\n\
project(mytool VERSION 1.2.3 LANGUAGES C)\n\
add_executable(mytool hello.c)\n",
)
.unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("mytool"));
assert_eq!(probe.version.as_deref(), Some("1.2.3"));
// Lowercase keyword, name without version.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("CMakeLists.txt"),
"project( just_a_name LANGUAGES CXX )\n",
)
.unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("just_a_name"));
assert_eq!(probe.version, None);
// No CMakeLists.txt: silent None.
let dir = tempdir().unwrap();
assert!(template.probe(dir.path()).is_none());
}
}
+5 -1
View File
@@ -62,6 +62,9 @@ mod tests {
depends,
native: false,
git: true,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
@@ -72,8 +75,9 @@ mod tests {
// Metapackage flavor: the depends list travels in the options.
let o = opts(vec!["hello".into(), "hello-data (>= 1.0)".into()]);
assert!(template.debian(&o).is_empty());
assert_eq!(template.architecture(), "all");
assert_eq!(template.architecture(&o), "all");
assert!(template.build_depends(&o).is_empty());
assert!(template.rules_extra(&o).is_empty());
let skeleton = template.skeleton(&o);
assert_eq!(skeleton.len(), 1);
+202
View File
@@ -0,0 +1,202 @@
//! The `go` template: a Go module built through dh-golang.
//!
//! The source stanza carries `XS-Go-Import-Path`, probed from the `module`
//! line of `go.mod` when the packaged tree has one, defaulting to the
//! package name (fresh skeletons embed the package name in their own
//! `go.mod`).
use std::path::Path;
use super::{OutputFile, Template, source_dir_of};
use crate::new::options::{NewOptions, TemplateId};
/// Go module (`go.mod`).
pub struct Go;
impl Template for Go {
fn id(&self) -> TemplateId {
TemplateId::Go
}
/// A stdlib-only `main.go` (no archive dependencies needed to build) and
/// the matching `go.mod` whose module path is the package name.
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
vec![
OutputFile::new(
"go.mod",
format!(
"module {name}\n\
\n\
go 1.21\n",
name = opts.name,
),
),
OutputFile::new(
"main.go",
format!(
"// Placeholder for {name}, generated by `pkh new`.\n\
package main\n\
\n\
import \"fmt\"\n\
\n\
func main() {{\n\
\tfmt.Println(\"Hello from {command}!\")\n\
}}\n",
name = opts.name,
command = opts.command,
),
),
]
}
/// No extra debian/ files: dh-golang drives the build.
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
Vec::new()
}
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
vec!["golang-any".to_string(), "dh-golang".to_string()]
}
fn architecture(&self, _opts: &NewOptions) -> &'static str {
"any"
}
fn rules_dh_line(&self) -> String {
"dh $@ --buildsystem=golang".to_string()
}
fn source_fields(&self, opts: &NewOptions) -> Vec<(String, String)> {
vec![("XS-Go-Import-Path".to_string(), import_path(opts))]
}
/// Name (and default command) from the `module` line of `go.mod`: the
/// last path segment is the conventional binary/package name.
fn probe(&self, dir: &Path) -> Option<super::ProbeResult> {
let module = read_module_line(dir)?;
let name = module.rsplit('/').next()?.to_string();
if name.is_empty() {
return None;
}
Some(super::ProbeResult {
name: Some(name.clone()),
command: Some(name),
..Default::default()
})
}
}
/// The `XS-Go-Import-Path` value: probed from `go.mod` when the packaged
/// tree has one, the package name otherwise (skeleton mode embeds the name
/// in the generated `go.mod` anyway).
fn import_path(opts: &NewOptions) -> String {
source_dir_of(opts)
.as_deref()
.and_then(read_module_line)
.unwrap_or_else(|| opts.name.clone())
}
/// The `module <path>` line of `dir/go.mod`, when present.
fn read_module_line(dir: &Path) -> Option<String> {
let content = std::fs::read_to_string(dir.join("go.mod")).ok()?;
for line in content.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("module ") {
let module = rest.trim();
if !module.is_empty() {
return Some(module.to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new::options::{License, SourceDir};
use tempfile::tempdir;
fn opts(source_dir: SourceDir) -> NewOptions {
NewOptions {
name: "mytool".into(),
template: TemplateId::Go,
source_dir,
upstream_version: "0.1.0".into(),
revision: 1,
summary: "A tool".into(),
long_description: "A tool".into(),
homepage: None,
license: 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: true,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
#[test]
fn go_template_shape() {
let o = opts(SourceDir::Skeleton);
let template = super::super::get(TemplateId::Go).unwrap();
assert_eq!(template.architecture(&o), "any");
assert_eq!(
template.build_depends(&o),
vec!["golang-any".to_string(), "dh-golang".to_string()]
);
assert_eq!(template.rules_dh_line(), "dh $@ --buildsystem=golang");
assert!(template.debian(&o).is_empty());
let skeleton = template.skeleton(&o);
assert!(
skeleton
.iter()
.any(|f| f.path == "go.mod" && f.contents.starts_with("module mytool\n"))
);
assert!(skeleton.iter().any(|f| f.path == "main.go"));
// Skeleton mode: no go.mod to probe, the import path is the name.
assert_eq!(
template.source_fields(&o),
vec![("XS-Go-Import-Path".to_string(), "mytool".to_string())]
);
}
#[test]
fn go_probe_reads_module_line() {
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("go.mod"),
"module example.com/org/mytool\n\ngo 1.21\n",
)
.unwrap();
let template = super::super::get(TemplateId::Go).unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("mytool"));
assert_eq!(probe.command.as_deref(), Some("mytool"));
// The probed module line wins over the package name for the import
// path when packaging an existing tree.
let o = opts(SourceDir::Path(dir.path().to_path_buf()));
assert_eq!(
template.source_fields(&o),
vec![(
"XS-Go-Import-Path".to_string(),
"example.com/org/mytool".to_string()
)]
);
// No go.mod: no probe result.
let empty = tempdir().unwrap();
assert!(template.probe(empty.path()).is_none());
}
}
+218
View File
@@ -0,0 +1,218 @@
//! The `makefile` template: a generic project driven by a plain `Makefile`.
//!
//! debhelper's makefile buildsystem runs `make` for the build and
//! `make install DESTDIR=...` when the Makefile carries an `install:` target
//! (missing targets are skipped gracefully), so plain `dh $@` plumbing is
//! enough here.
use std::path::Path;
use super::{OutputFile, Template, source_dir_of};
use crate::new::options::{NewOptions, SourceDir, TemplateId};
/// Generic Makefile-based project.
pub struct Makefile;
impl Template for Makefile {
fn id(&self) -> TemplateId {
TemplateId::Makefile
}
/// A `hello.c` plus a `Makefile` with `all`/`install`/`clean` targets;
/// `install` honors `DESTDIR` and copies the binary to
/// `$(DESTDIR)/usr/bin/`.
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
vec![
super::meson::hello_c(opts),
OutputFile::new(
"Makefile",
format!(
"CC ?= cc\n\
CFLAGS ?= -O2 -Wall -Wextra\n\
PREFIX ?= /usr\n\
\n\
all: {command}\n\
\n\
{command}: hello.c\n\
\t$(CC) $(CFLAGS) -o $@ hello.c\n\
\n\
install: {command}\n\
\tinstall -Dm755 {command} $(DESTDIR)$(PREFIX)/bin/{command}\n\
\n\
clean:\n\
\trm -f {command}\n\
\n\
.PHONY: all install clean\n",
command = opts.command,
),
),
]
}
/// `debian/install` mapping the built binary into `/usr/bin`, generated
/// only when the packaged Makefile carries a phony `install:` target:
/// for skeletons that is known by construction; when packaging an
/// existing tree the Makefile is probed instead (no `debian/install` is
/// emitted there — the source-relative mapping of an unknown artifact is
/// only the project's to write, and `make install` already ran).
fn debian(&self, opts: &NewOptions) -> Vec<OutputFile> {
match &opts.source_dir {
SourceDir::Skeleton => {
vec![OutputFile::new(
"debian/install",
format!("{} usr/bin/{}\n", opts.command, opts.command),
)]
}
_ => {
if let Some(dir) = source_dir_of(opts)
&& phony_install_target(&dir.join("Makefile")).is_some()
{
log::info!(
"Makefile carries a phony 'install:' target: \
dh_auto_install will run 'make install DESTDIR=...'"
);
} else {
log::info!(
"Makefile has no phony 'install:' target: \
dh_auto_install will skip the install step; write a \
debian/install file to map build artifacts manually"
);
}
Vec::new()
}
}
}
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
vec!["build-essential".to_string()]
}
fn architecture(&self, _opts: &NewOptions) -> &'static str {
"any"
}
}
/// The name of the phony `install:` target of the Makefile at `path`, when
/// there is one: an unindented `install:` rule whose name also appears in a
/// `.PHONY:` declaration. `None` when the file is missing or carries no such
/// target. (A deliberately minimal line-oriented heuristic, like the other
/// project-file readers of the templates.)
pub fn phony_install_target(path: &Path) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let mut has_install_rule = false;
let mut phony_mentions_install = false;
for line in content.lines() {
if let Some(phony) = line.strip_prefix(".PHONY:")
&& phony.split_whitespace().any(|target| target == "install")
{
phony_mentions_install = true;
}
// Target rules start at column 0; recipes are indented, and special
// targets, comments and directives are excluded by the prefix check.
if !line.starts_with(['\t', ' ', '.', '#']) && line.starts_with("install:") {
has_install_rule = true;
}
}
(has_install_rule && phony_mentions_install).then(|| "install".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new::options::{License, SourceDir};
use tempfile::tempdir;
fn opts(source_dir: SourceDir) -> NewOptions {
NewOptions {
name: "mytool".into(),
template: TemplateId::Makefile,
source_dir,
upstream_version: "0.1.0".into(),
revision: 1,
summary: "A tool".into(),
long_description: "A tool".into(),
homepage: None,
license: 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: true,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
#[test]
fn makefile_template_shape() {
let o = opts(SourceDir::Skeleton);
let template = super::super::get(TemplateId::Makefile).unwrap();
assert_eq!(template.architecture(&o), "any");
assert_eq!(
template.build_depends(&o),
vec!["build-essential".to_string()]
);
assert_eq!(template.rules_dh_line(), "dh $@");
assert!(template.rules_extra(&o).is_empty());
// Skeleton: hello.c + Makefile with all/install/clean, and the
// phony install target maps to debian/install.
let skeleton = template.skeleton(&o);
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
let makefile = skeleton
.iter()
.find(|f| f.path == "Makefile")
.expect("Makefile skeleton");
assert!(makefile.contents.contains("all: mytool\n"));
assert!(
makefile
.contents
.contains("install -Dm755 mytool $(DESTDIR)$(PREFIX)/bin/mytool")
);
assert!(makefile.contents.contains(".PHONY: all install clean"));
assert!(
makefile
.contents
.contains("\t$(CC) $(CFLAGS) -o $@ hello.c")
);
let debian = template.debian(&o);
assert_eq!(debian.len(), 1);
assert_eq!(debian[0].path, "debian/install");
assert_eq!(debian[0].contents, "mytool usr/bin/mytool\n");
// Existing tree: nothing is emitted (probe log only).
let o = opts(SourceDir::Here);
assert!(template.debian(&o).is_empty());
}
#[test]
fn phony_install_target_detection() {
let dir = tempdir().unwrap();
let path = dir.path().join("Makefile");
// Phony install target: detected.
std::fs::write(
&path,
"all:\n\t@echo\n\n.PHONY: all install clean\ninstall:\n\t@echo install\n",
)
.unwrap();
assert!(phony_install_target(&path).is_some());
// install: without .PHONY: not detected.
std::fs::write(&path, "all:\n\t@echo\ninstall:\n\t@echo install\n").unwrap();
assert!(phony_install_target(&path).is_none());
// .PHONY mentioning install but no install: rule: not detected.
std::fs::write(&path, ".PHONY: install\nall:\n\t@echo\n").unwrap();
assert!(phony_install_target(&path).is_none());
// Missing file.
assert!(phony_install_target(&dir.path().join("missing.mk")).is_none());
}
}
+192
View File
@@ -0,0 +1,192 @@
//! The `meson` template: a C/C++ project built with Meson through the
//! debhelper meson buildsystem.
use std::path::Path;
use regex::Regex;
use super::{OutputFile, ProbeResult, Template};
use crate::new::options::{NewOptions, TemplateId};
/// C/C++ with Meson (`meson.build`).
pub struct Meson;
impl Template for Meson {
fn id(&self) -> TemplateId {
TemplateId::Meson
}
/// A minimal `meson.build` (project declaration + one installed
/// executable) and the classic `hello.c`.
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
vec![
OutputFile::new(
"meson.build",
format!(
"project('{name}', version: '{version}', license: '{license}', \
default_options: ['c_std=c11'])\n\
\n\
executable('{command}', 'hello.c', install: true)\n",
name = opts.name,
version = opts.upstream_version,
license = opts.license.spdx(),
command = opts.command,
),
),
hello_c(opts),
]
}
/// No extra debian/ files: debhelper's meson buildsystem handles the
/// configure/build/install steps.
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
Vec::new()
}
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
let mut deps = vec!["meson".to_string()];
if opts.pkg_config {
deps.push("pkg-config".to_string());
}
deps
}
fn architecture(&self, _opts: &NewOptions) -> &'static str {
"any"
}
fn rules_dh_line(&self) -> String {
"dh $@ --buildsystem=meson".to_string()
}
/// Project name and version from the `project('name', version: …)`
/// declaration.
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
let content = std::fs::read_to_string(dir.join("meson.build")).ok()?;
static PROJECT_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let regex = PROJECT_REGEX.get_or_init(|| {
Regex::new(r"(?m)^\s*project\(\s*'([^']+)'\s*(?:,\s*version\s*:\s*'([^']+)')?").unwrap()
});
let caps = regex.captures(&content)?;
Some(ProbeResult {
name: Some(caps[1].to_string()),
version: caps.get(2).map(|v| v.as_str().to_string()),
..Default::default()
})
}
}
/// The shared `hello.c` placeholder of the C/C++ skeletons.
pub(super) fn hello_c(opts: &NewOptions) -> OutputFile {
OutputFile::new(
"hello.c",
format!(
"#include <stdio.h>\n\
\n\
/* Placeholder for {name}, generated by `pkh new`. */\n\
int main(void)\n\
{{\n\
\tprintf(\"Hello from {command}!\\n\");\n\
\treturn 0;\n\
}}\n",
name = opts.name,
command = opts.command,
),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new::options::{License, SourceDir};
use tempfile::tempdir;
fn opts() -> NewOptions {
NewOptions {
name: "mytool".into(),
template: TemplateId::Meson,
source_dir: SourceDir::Skeleton,
upstream_version: "0.1.0".into(),
revision: 1,
summary: "A tool".into(),
long_description: "A tool".into(),
homepage: None,
license: 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: true,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
#[test]
fn meson_template_shape() {
let o = opts();
let template = super::super::get(TemplateId::Meson).unwrap();
assert_eq!(template.architecture(&o), "any");
assert_eq!(template.build_depends(&o), vec!["meson".to_string()]);
assert_eq!(template.rules_dh_line(), "dh $@ --buildsystem=meson");
assert!(template.rules_extra(&o).is_empty());
assert!(template.debian(&o).is_empty());
let skeleton = template.skeleton(&o);
let meson_build = skeleton
.iter()
.find(|f| f.path == "meson.build")
.expect("meson.build skeleton");
assert!(meson_build.contents.contains("project('mytool'"));
assert!(meson_build.contents.contains("version: '0.1.0'"));
assert!(
meson_build
.contents
.contains("executable('mytool', 'hello.c', install: true)")
);
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
}
#[test]
fn meson_probe_reads_project_declaration() {
let template = super::super::get(TemplateId::Meson).unwrap();
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("meson.build"),
"project('mytool', version: '1.2.3', license: 'MIT', default_options: ['c_std=c11'])\n",
)
.unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("mytool"));
assert_eq!(probe.version.as_deref(), Some("1.2.3"));
// Version is optional in project().
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("meson.build"), "project('just-a-name')\n").unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("just-a-name"));
assert_eq!(probe.version, None);
// No meson.build: silent None.
let dir = tempdir().unwrap();
assert!(template.probe(dir.path()).is_none());
}
#[test]
fn pkg_config_opt_in_extends_build_depends() {
let template = super::super::get(TemplateId::Meson).unwrap();
let mut o = opts();
assert_eq!(template.build_depends(&o), vec!["meson".to_string()]);
o.pkg_config = true;
assert_eq!(
template.build_depends(&o),
vec!["meson".to_string(), "pkg-config".to_string()]
);
}
}
+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 $@");
}
}
+626
View File
@@ -0,0 +1,626 @@
//! The `python` template: a PEP 517 project built with pybuild.
//!
//! The PEP 517 backend is read from `pyproject.toml` (`build-backend =
//! …`) with a deliberately minimal line-oriented reader (see
//! [`read_pyproject`]) — pkh has no TOML dependency, and only a handful of
//! keys matter here. A bare `setup.py`/`setup.cfg` project falls back to
//! setuptools without the `pybuild-plugin-pyproject` helper.
use std::path::Path;
use regex::Regex;
use super::{OutputFile, ProbeResult, Template, source_dir_of};
use crate::new::options::{NewOptions, TemplateId};
/// Python project (`pyproject.toml` / `setup.py` / `setup.cfg`).
pub struct Python;
/// The PEP 517 backend of a project.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Backend {
/// `setuptools.build_meta` (also the PEP 517 default when
/// `build-backend` is missing).
Setuptools,
/// `poetry.core.masonry.api`
Poetry,
/// `hatchling.build`
Hatchling,
/// `flit_core.buildapi` (and any other `flit_core` backend)
Flit,
}
impl Backend {
/// The Debian package providing this backend.
fn package(self) -> &'static str {
match self {
Backend::Setuptools => "python3-setuptools",
Backend::Poetry => "python3-poetry-core",
Backend::Hatchling => "python3-hatchling",
Backend::Flit => "python3-flit-core",
}
}
}
/// Map the raw `build-backend` value to the backend enum; unknown values
/// fall back to setuptools (the PEP 517 default tooling answer).
fn backend_of_value(value: &str) -> Backend {
if value.starts_with("poetry") {
Backend::Poetry
} else if value.starts_with("hatchling") {
Backend::Hatchling
} else if value.starts_with("flit_core") {
Backend::Flit
} else {
// setuptools.build_meta, setuptools.build_meta:__legacy__, unknown.
Backend::Setuptools
}
}
/// Whether the packaged project is pyproject-based (skeletons always are:
/// the generated skeleton carries a `pyproject.toml`), which decides whether
/// `pybuild-plugin-pyproject` is needed in Build-Depends.
fn uses_pyproject(opts: &NewOptions) -> bool {
match source_dir_of(opts) {
Some(dir) => dir.join("pyproject.toml").exists(),
// Skeleton mode renders a pyproject.toml.
None => true,
}
}
/// The backend of the packaged project: read from `pyproject.toml` when
/// there is one (missing `build-backend` = the setuptools default), falling
/// back to setuptools for bare `setup.py`/`setup.cfg` projects. Skeleton
/// mode uses setuptools (the generated backend).
fn backend(opts: &NewOptions) -> Backend {
match source_dir_of(opts) {
Some(dir) => read_pyproject(&dir.join("pyproject.toml"))
.and_then(|project| project.backend)
.map(|value| backend_of_value(&value))
.unwrap_or(Backend::Setuptools),
None => Backend::Setuptools,
}
}
/// A valid Python identifier derived from the package name: dpkg names may
/// carry `+`/`.` and may start with a digit, none of which a module name
/// may.
fn module_name(opts: &NewOptions) -> String {
let mut module = opts.name.replace(['-', '.', '+'], "_");
if module.starts_with(|c: char| c.is_ascii_digit()) {
module = format!("_{module}");
}
module
}
/// Whether the project hints at compiled C extensions (pyo3 in Cargo.toml,
/// `ext_modules` / `Extension` imports in setup.py): those need
/// `Architecture: any` + `python3-all-dev` instead of `Architecture: all`.
fn c_extension_hints(opts: &NewOptions) -> bool {
let Some(dir) = source_dir_of(opts) else {
return false;
};
if let Ok(cargo) = std::fs::read_to_string(dir.join("Cargo.toml"))
&& cargo.contains("pyo3")
{
return true;
}
if let Ok(setup) = std::fs::read_to_string(dir.join("setup.py"))
&& (setup.contains("ext_modules") || setup.contains("from setuptools import Extension"))
{
return true;
}
false
}
impl Template for Python {
fn id(&self) -> TemplateId {
TemplateId::Python
}
/// A minimal setuptools-based `pyproject.toml` with one console script,
/// plus the one-module package providing it.
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
let module = module_name(opts);
vec![
OutputFile::new(
"pyproject.toml",
format!(
"[build-system]\n\
requires = [\"setuptools\"]\n\
build-backend = \"setuptools.build_meta\"\n\
\n\
[project]\n\
name = \"{name}\"\n\
version = \"{version}\"\n\
description = \"{summary}\"\n\
requires-python = \">=3.8\"\n\
\n\
[project.scripts]\n\
{command} = \"{module}:main\"\n",
name = opts.name,
version = opts.upstream_version,
summary = opts.summary,
command = opts.command,
module = module,
),
),
OutputFile::new(
format!("{module}/__init__.py"),
format!(
"\"\"\"Placeholder for {name}, generated by `pkh new`.\"\"\"\n\
\n\
\n\
def main() -> None:\n\
\x20 print(\"Hello from {command}!\")\n",
name = opts.name,
command = opts.command,
),
),
]
}
/// No extra debian/ files: pybuild installs the package and its console
/// entry points (under `/usr/bin`) automatically.
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
Vec::new()
}
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
let mut deps = vec!["dh-python".to_string(), "python3-all".to_string()];
if c_extension_hints(opts) {
deps.push("python3-all-dev".to_string());
}
if uses_pyproject(opts) {
deps.push("pybuild-plugin-pyproject".to_string());
}
deps.push(backend(opts).package().to_string());
deps
}
/// `all` unless the project hints at compiled C extensions.
fn architecture(&self, opts: &NewOptions) -> &'static str {
if c_extension_hints(opts) {
"any"
} else {
"all"
}
}
fn rules_dh_line(&self) -> String {
"dh $@ --with python3 --buildsystem=pybuild".to_string()
}
fn source_fields(&self, _opts: &NewOptions) -> Vec<(String, String)> {
Vec::new()
}
/// Metadata from the `[project]` section of `pyproject.toml` (name,
/// version, description, homepage, license, first console script), with
/// a minimal `setup.py` `name=…` fallback.
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
let project = read_pyproject(&dir.join("pyproject.toml"));
if let Some(project) = project {
let result = ProbeResult {
name: project.name,
version: project.version,
description: project.description,
homepage: project.homepage,
license: project.license,
command: project.script,
};
if result.name.is_some()
|| result.version.is_some()
|| result.description.is_some()
|| result.command.is_some()
{
return Some(result);
}
return None;
}
// Bare setup.py: catch the `name='…'` argument only.
let setup = std::fs::read_to_string(dir.join("setup.py")).ok()?;
static NAME_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let regex =
NAME_REGEX.get_or_init(|| Regex::new(r#"name\s*=\s*["']([^"']+)["']"#).unwrap());
let name = regex.captures(&setup)?.get(1)?.as_str().to_string();
Some(ProbeResult {
name: Some(name),
..Default::default()
})
}
}
/// The few `pyproject.toml` keys pkh new cares about.
#[derive(Debug, Default, PartialEq, Eq, Clone)]
pub struct PyProject {
/// Raw `build-backend` value of `[build-system]`.
pub backend: Option<String>,
/// `name` of `[project]`.
pub name: Option<String>,
/// `version` of `[project]`.
pub version: Option<String>,
/// `description` of `[project]`.
pub description: Option<String>,
/// `Homepage` of `[project.urls]`.
pub homepage: Option<String>,
/// `license` of `[project]` (quoted-string form).
pub license: Option<String>,
/// First console-script key of `[project.scripts]`.
pub script: Option<String>,
}
/// Minimal line-oriented reader for the `pyproject.toml` keys pkh new needs:
/// it tracks the current `[section]` header, matches `key = value` pairs at
/// the start of a line and tolerates quotes and comments. It is not a TOML
/// parser — anything it cannot understand is simply ignored and the caller
/// falls back to the defaults (pkh has no TOML dependency, and a full parser
/// would be out of proportion for a handful of keys).
pub fn read_pyproject(path: &Path) -> Option<PyProject> {
let content = std::fs::read_to_string(path).ok()?;
let mut project = PyProject::default();
let mut section = String::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
section = header.trim().to_string();
continue;
}
let Some((key, value)) = split_key_value(line) else {
continue;
};
match (section.as_str(), key) {
("build-system", "build-backend") => project.backend = Some(value),
("project", "name") => project.name = Some(value),
("project", "version") => project.version = Some(value),
("project", "description") => project.description = Some(value),
("project", "license") => project.license = license_text(&value),
("project.urls", "Homepage") => project.homepage = Some(value),
("project.scripts", key) => {
if project.script.is_none() {
project.script = Some(key.to_string());
}
}
_ => {}
}
}
if project == PyProject::default() {
None
} else {
Some(project)
}
}
/// The license out of a raw `license =` value: plain (unquoted by
/// [`split_key_value`]) strings pass through, while the PEP 621 inline-table
/// form `{text = "…"}` yields its `text` key. Anything else (other table
/// forms) is ignored.
fn license_text(value: &str) -> Option<String> {
if value.starts_with('{') {
static TEXT_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let regex = TEXT_REGEX.get_or_init(|| Regex::new(r#"text\s*=\s*"([^"]+)""#).unwrap());
Some(regex.captures(value)?.get(1)?.as_str().to_string())
} else {
Some(value.to_string())
}
}
/// Split a `key = value` line into its unquoted parts, ignoring inline
/// comments outside of the (first) quoted string. Multi-line values (arrays,
/// tables) are not supported on purpose: they never carry the keys read
/// here.
fn split_key_value(line: &str) -> Option<(&str, String)> {
let (key, value) = line.split_once('=')?;
let key = key.trim();
if key.is_empty() || key.contains(' ') {
return None;
}
let value = value.trim();
// Quoted string: strip the quotes; a '#' inside the quotes is literal.
let value = if let Some(rest) = value.strip_prefix('"') {
rest.split_once('"')?.0.to_string()
} else if let Some(rest) = value.strip_prefix('\'') {
rest.split_once('\'')?.0.to_string()
} else {
// Bare value (e.g. true, 5, or {…} tables): cut the inline comment.
match value.split_once('#') {
Some((bare, _)) => bare.trim().to_string(),
None => value.to_string(),
}
};
Some((key, value))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new::options::{License, SourceDir};
use tempfile::tempdir;
fn opts(source_dir: SourceDir) -> NewOptions {
NewOptions {
name: "mytool".into(),
template: TemplateId::Python,
source_dir,
upstream_version: "0.1.0".into(),
revision: 1,
summary: "A tool".into(),
long_description: "A tool".into(),
homepage: None,
license: 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: true,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
fn source_opts(dir: &Path) -> NewOptions {
opts(SourceDir::Path(dir.to_path_buf()))
}
#[test]
fn python_skeleton_shape() {
let o = opts(SourceDir::Skeleton);
let template = super::super::get(TemplateId::Python).unwrap();
assert_eq!(template.architecture(&o), "all");
assert!(template.debian(&o).is_empty());
assert_eq!(
template.rules_dh_line(),
"dh $@ --with python3 --buildsystem=pybuild"
);
let skeleton = template.skeleton(&o);
let pyproject = skeleton
.iter()
.find(|f| f.path == "pyproject.toml")
.expect("pyproject skeleton");
assert!(
pyproject
.contents
.contains("build-backend = \"setuptools.build_meta\"")
);
assert!(pyproject.contents.contains("name = \"mytool\""));
assert!(pyproject.contents.contains("mytool = \"mytool:main\""));
let module = skeleton
.iter()
.find(|f| f.path == "mytool/__init__.py")
.expect("module skeleton");
assert!(module.contents.contains("def main()"));
}
/// dpkg names may carry `+`/`.` and start with a digit — none of which a
/// Python module name may.
#[test]
fn module_name_is_a_valid_python_identifier() {
let o = NewOptions {
name: "9x.tool+".into(),
..opts(SourceDir::Skeleton)
};
assert_eq!(module_name(&o), "_9x_tool_");
let o = NewOptions {
name: "my-tool".into(),
..opts(SourceDir::Skeleton)
};
assert_eq!(module_name(&o), "my_tool");
// The skeleton module path and the pyproject script agree.
let template = super::super::get(TemplateId::Python).unwrap();
let skeleton = template.skeleton(&o);
assert!(skeleton.iter().any(|f| f.path == "my_tool/__init__.py"));
assert!(skeleton.iter().any(|f| {
f.path == "pyproject.toml" && f.contents.contains("mytool = \"my_tool:main\"")
}));
}
/// The backend-detection table: `build-backend` value → Debian package,
/// plus the missing-file/missing-key/bare-setup.py defaults.
#[test]
fn backend_detection_table() {
// Raw value mapping.
for (value, expected) in [
("setuptools.build_meta", Backend::Setuptools),
("setuptools.build_meta:__legacy__", Backend::Setuptools),
("poetry.core.masonry.api", Backend::Poetry),
("poetry.core.masonry.api.something", Backend::Poetry),
("hatchling.build", Backend::Hatchling),
("flit_core.buildapi", Backend::Flit),
("flit_core.wheel", Backend::Flit),
] {
assert_eq!(backend_of_value(value), expected, "{value}");
}
// Unknown values fall back to setuptools.
assert_eq!(backend_of_value("mystery.backend"), Backend::Setuptools);
let template = super::super::get(TemplateId::Python).unwrap();
// No source dir (skeleton): setuptools + pyproject plugin.
assert_eq!(backend(&opts(SourceDir::Skeleton)), Backend::Setuptools);
assert!(uses_pyproject(&opts(SourceDir::Skeleton)));
// pyproject.toml with each backend, [build-system] section-aware.
let cases = [
("setuptools.build_meta", "python3-setuptools"),
("poetry.core.masonry.api", "python3-poetry-core"),
("hatchling.build", "python3-hatchling"),
("flit_core.buildapi", "python3-flit-core"),
];
for (value, package) in cases {
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("pyproject.toml"),
format!(
"[other-section]\nbuild-backend = \"ignored\"\n\n\
[build-system]\nrequires = [\"x\"]\n\
build-backend = \"{value}\"\n"
),
)
.unwrap();
let o = source_opts(dir.path());
assert_eq!(backend(&o), backend_of_value(value), "{value}");
let deps = template.build_depends(&o);
assert!(deps.contains(&package.to_string()), "{deps:?}");
assert!(deps.contains(&"pybuild-plugin-pyproject".to_string()));
}
// pyproject.toml without build-backend: setuptools (PEP 517 default).
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("pyproject.toml"),
"[project]\nname = \"x\"\n",
)
.unwrap();
let o = source_opts(dir.path());
assert_eq!(backend(&o), Backend::Setuptools);
// Bare setup.py: setuptools WITHOUT the pyproject plugin.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("setup.py"),
"from setuptools import setup\nsetup()\n",
)
.unwrap();
let o = source_opts(dir.path());
assert!(!uses_pyproject(&o));
let deps = template.build_depends(&o);
assert!(
!deps.contains(&"pybuild-plugin-pyproject".to_string()),
"{deps:?}"
);
assert!(deps.contains(&"python3-setuptools".to_string()));
}
#[test]
fn c_extension_hints_flip_architecture_and_deps() {
let template = super::super::get(TemplateId::Python).unwrap();
// pyo3 in Cargo.toml.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[dependencies]\npyo3 = \"0.22\"\n",
)
.unwrap();
let o = source_opts(dir.path());
assert!(c_extension_hints(&o));
let deps = template.build_depends(&o);
assert!(deps.contains(&"python3-all-dev".to_string()), "{deps:?}");
// ext_modules in setup.py.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("setup.py"),
"from setuptools import setup, Extension\nsetup(ext_modules=[])\n",
)
.unwrap();
let o = source_opts(dir.path());
assert!(c_extension_hints(&o));
// Nothing relevant: no hints.
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("pyproject.toml"), "[project]\n").unwrap();
let o = source_opts(dir.path());
assert!(!c_extension_hints(&o));
assert_eq!(template.build_depends(&o).len(), 4); // dh-python, python3-all, plugin, setuptools
}
#[test]
fn probe_reads_project_and_scripts() {
let template = super::super::get(TemplateId::Python).unwrap();
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("pyproject.toml"),
"[build-system]\n\
build-backend = \"hatchling.build\" # inline comment\n\
\n\
[project]\n\
name = \"my-tool\"\n\
version = \"1.2.3\"\n\
description = \"Does things\"\n\
license = \"MIT\"\n\
\n\
[project.urls]\n\
Homepage = \"https://example.com/my-tool\"\n\
\n\
[project.scripts]\n\
mycli = \"my_tool.cli:main\"\n\
other = \"my_tool.other:run\"\n",
)
.unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("my-tool"));
assert_eq!(probe.version.as_deref(), Some("1.2.3"));
assert_eq!(probe.description.as_deref(), Some("Does things"));
assert_eq!(probe.license.as_deref(), Some("MIT"));
assert_eq!(
probe.homepage.as_deref(),
Some("https://example.com/my-tool")
);
// First [project.scripts] key becomes the command.
assert_eq!(probe.command.as_deref(), Some("mycli"));
// Bare setup.py: the name= argument.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("setup.py"),
"setup(\n name='legacy-tool',\n version=\"9.9\",\n)\n",
)
.unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("legacy-tool"));
assert_eq!(probe.version, None);
// Nothing readable: silent None.
let dir = tempdir().unwrap();
assert!(template.probe(dir.path()).is_none());
}
#[test]
fn pyproject_reader_is_section_aware_and_tolerant() {
let dir = tempdir().unwrap();
let path = dir.path().join("pyproject.toml");
std::fs::write(
&path,
"# leading comment\n\
[tool.black]\n\
name = \"should not leak\"\n\
\n\
[project]\n\
name = \"quoted-name\"\n\
description = 'single quoted'\n\
version = \"1.0\" # trailing comment\n\
license = {text = \"MIT\"}\n\
requires-python = \">=3.8\"\n",
)
.unwrap();
let project = read_pyproject(&path).unwrap();
assert_eq!(project.name.as_deref(), Some("quoted-name"));
assert_eq!(project.description.as_deref(), Some("single quoted"));
assert_eq!(project.version.as_deref(), Some("1.0"));
// The PEP 621 inline-table license form yields its text key.
assert_eq!(project.license.as_deref(), Some("MIT"));
// Missing file.
assert!(read_pyproject(&dir.path().join("missing.toml")).is_none());
// Only comments: no project.
std::fs::write(&path, "# nothing\n").unwrap();
assert_eq!(read_pyproject(&path), None);
}
}
+498
View File
@@ -0,0 +1,498 @@
//! The `rust` template: a Cargo project shipped as a **vendored** build.
//!
//! Standard Debian practice (debcargo → dh-cargo → registry deps) needs every
//! Cargo dependency as a `librust-*-dev` archive package — unusable for a
//! brand-new program. v1 therefore vendors at scaffold time: `cargo vendor`
//! runs over the freshly written tree (before the orig tarball is created, so
//! `vendor/` travels inside it), and `debian/rules` builds offline with the
//! source replacement. When host `cargo` is missing or vendoring fails, the
//! scaffold continues with a loud warning — the package will not build until
//! the user vendors manually.
use std::path::Path;
use serde_json::Value;
use super::{OutputFile, ProbeResult, Template, find_on_path, source_dir_of};
use crate::new::options::{NewOptions, SourceDir, TemplateId};
/// Rust project (`Cargo.toml`).
pub struct Rust;
/// The source-replacement configuration, used when `cargo vendor` did not
/// print one itself (old cargo versions, empty output).
const FALLBACK_VENDOR_CONFIG: &str = "[source.crates-io]\nreplace-with = \"vendored-sources\"\n\n[source.vendored-sources]\ndirectory = \"vendor\"";
/// The cargo crate name of the skeleton: dpkg package names may carry `+`
/// or `.`, which cargo rejects in package names.
fn crate_name(opts: &NewOptions) -> String {
opts.name.replace(['+', '.'], "_")
}
impl Template for Rust {
fn id(&self) -> TemplateId {
TemplateId::Rust
}
/// A zero-dependency `Cargo.toml` and the matching `src/main.rs`.
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
vec![
OutputFile::new(
"Cargo.toml",
format!(
"[package]\n\
name = \"{name}\"\n\
version = \"{version}\"\n\
edition = \"2021\"\n\
\n\
[dependencies]\n",
name = crate_name(opts),
version = opts.upstream_version,
),
),
OutputFile::new(
"src/main.rs",
format!(
"// Placeholder for {name}, generated by `pkh new`.\n\
fn main() {{\n\
\tprintln!(\"Hello from {command}!\");\n\
}}\n",
name = opts.name,
command = opts.command,
),
),
]
}
/// No extra debian/ files: the vendored build lives entirely in the
/// rules overrides.
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
Vec::new()
}
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
vec!["cargo:native".to_string(), "rustc:native".to_string()]
}
fn architecture(&self, _opts: &NewOptions) -> &'static str {
"any"
}
/// The vendored build overrides. `--locked` is used only when the
/// packaged tree already carries a `Cargo.lock` (fresh skeletons have
/// none yet); omitting it is always safe. The built artifact of a
/// skeleton is named after its crate (a sanitized package name) and
/// installed under the command name.
fn rules_extra(&self, opts: &NewOptions) -> String {
let locked = if lockfile_present(opts) {
" --locked"
} else {
""
};
// The skeleton's cargo artifact is the crate name; for an existing
// project the (probed or answered) command names the binary.
let artifact = match opts.source_dir {
SourceDir::Skeleton => crate_name(opts),
_ => opts.command.clone(),
};
format!(
"override_dh_auto_build:\n\
\tcargo build --release --offline{locked}\n\
\n\
override_dh_auto_install:\n\
\tinstall -Dm755 target/release/{artifact} debian/{name}/usr/bin/{command}\n\
\n\
override_dh_auto_test:\n\
\tcargo test --release --offline{locked}\n\
\n\
override_dh_auto_clean:\n\
\tcargo clean\n",
locked = locked,
artifact = artifact,
command = opts.command,
name = opts.name,
)
}
/// Name, version, description, homepage, license and first binary from
/// `cargo metadata --no-deps` (when host cargo is available), with a
/// minimal line-parse of `Cargo.toml` as fallback.
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
if let Some(result) = probe_cargo_metadata(dir) {
return Some(result);
}
probe_cargo_toml(dir)
}
/// Vendor the Cargo dependencies into the freshly written tree: run
/// `cargo vendor` in it and write `.cargo/config.toml` with the printed
/// source replacement plus `offline = true`, so the build never touches
/// the network. Failures warn loudly and continue: the scaffold stays in
/// place, the package just will not build until vendored manually.
fn post_write(
&self,
_opts: &NewOptions,
tree: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
if !tree.join("Cargo.toml").exists() {
return Ok(());
}
let Some(cargo) = find_on_path("cargo") else {
log::warn!(
"cargo was not found on PATH: the Rust package will NOT build \
until its dependencies are vendored. Run `cargo vendor` in the \
tree and add the printed source replacement to \
.cargo/config.toml (with `[net] offline = true`)."
);
return Ok(());
};
log::info!("Vendoring Cargo dependencies (`cargo vendor`) — needs one network sync");
match std::process::Command::new(&cargo)
.arg("vendor")
.current_dir(tree)
.output()
{
Ok(output) if output.status.success() => {
let printed = String::from_utf8_lossy(&output.stdout).trim().to_string();
let snippet = if printed.contains("[source.") {
printed
} else {
FALLBACK_VENDOR_CONFIG.to_string()
};
let config_path = tree.join(".cargo/config.toml");
if config_path.exists() {
log::warn!(
"'{}' already exists: the vendored-source replacement \
printed by `cargo vendor` was NOT written there; add it \
manually (plus `[net] offline = true`).",
config_path.display()
);
return Ok(());
}
std::fs::create_dir_all(config_path.parent().unwrap_or(tree))?;
std::fs::write(
&config_path,
format!("{snippet}\n\n[net]\noffline = true\n"),
)?;
log::info!(
"Vendored sources and '{}' written; the package builds \
fully offline",
config_path.display()
);
}
Ok(output) => {
log::warn!(
"`cargo vendor` failed ({}): the package will NOT build until \
its dependencies are vendored. Run `cargo vendor` in the tree \
and add the printed source replacement to .cargo/config.toml. \
Last stderr line: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
.lines()
.last()
.unwrap_or("(no output)")
);
}
Err(e) => {
log::warn!(
"could not run `cargo vendor` ({e}): the package will NOT \
build until its dependencies are vendored. Run \
`cargo vendor` in the tree and add the printed source \
replacement to .cargo/config.toml."
);
}
}
Ok(())
}
}
/// Whether the packaged tree carries a `Cargo.lock` (skeletons do not yet).
fn lockfile_present(opts: &NewOptions) -> bool {
source_dir_of(opts).is_some_and(|dir| dir.join("Cargo.lock").exists())
}
/// Probe through `cargo metadata --no-deps --format-version 1`: silent `None`
/// when cargo is unavailable or fails.
fn probe_cargo_metadata(dir: &Path) -> Option<ProbeResult> {
let cargo = find_on_path("cargo")?;
let output = std::process::Command::new(cargo)
.args(["metadata", "--no-deps", "--format-version", "1"])
.current_dir(dir)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let metadata: Value = serde_json::from_slice(&output.stdout).ok()?;
let package = metadata.get("packages")?.as_array()?.first()?;
let command = package
.get("targets")?
.as_array()?
.iter()
.find(|target| {
target
.get("kind")
.and_then(|kind| kind.as_array())
.is_some_and(|kinds| kinds.iter().any(|k| k.as_str() == Some("bin")))
})
.and_then(|target| target.get("name"))
.and_then(|name| name.as_str())
.map(str::to_string);
let field = |key: &str| {
package
.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
Some(ProbeResult {
name: field("name"),
version: field("version"),
description: field("description"),
homepage: field("homepage"),
license: field("license"),
command,
})
}
/// Minimal line-parse fallback for `Cargo.toml` (no TOML dependency): the
/// `key = value` pairs of the `[package]` section.
fn probe_cargo_toml(dir: &Path) -> Option<ProbeResult> {
let content = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;
let mut result = ProbeResult::default();
let mut in_package = false;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
in_package = header.trim() == "package";
continue;
}
if !in_package {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value.trim().trim_matches('"').trim_matches('\'').trim();
match key.trim() {
"name" => result.name = Some(value.to_string()),
"version" => result.version = Some(value.to_string()),
"description" => result.description = Some(value.to_string()),
"homepage" => result.homepage = Some(value.to_string()),
"license" => result.license = Some(value.to_string()),
_ => {}
}
}
if result.name.is_none() && result.version.is_none() {
None
} else {
Some(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new::options::{License, SourceDir};
use tempfile::tempdir;
fn opts(source_dir: SourceDir) -> NewOptions {
NewOptions {
name: "mytool".into(),
template: TemplateId::Rust,
source_dir,
upstream_version: "0.1.0".into(),
revision: 1,
summary: "A tool".into(),
long_description: "A tool".into(),
homepage: None,
license: 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: true,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
#[test]
fn rust_template_shape() {
let o = opts(SourceDir::Skeleton);
let template = super::super::get(TemplateId::Rust).unwrap();
assert_eq!(template.architecture(&o), "any");
assert_eq!(
template.build_depends(&o),
vec!["cargo:native".to_string(), "rustc:native".to_string()]
);
assert_eq!(template.rules_dh_line(), "dh $@");
assert!(template.debian(&o).is_empty());
// Skeleton: Cargo.toml + src/main.rs.
let skeleton = template.skeleton(&o);
assert!(
skeleton
.iter()
.any(|f| f.path == "Cargo.toml" && f.contents.contains("name = \"mytool\""))
);
assert!(skeleton.iter().any(|f| f.path == "src/main.rs"));
// Fresh skeleton: no Cargo.lock, so no --locked flag anywhere.
let extra = template.rules_extra(&o);
assert!(extra.contains("override_dh_auto_build:\n\tcargo build --release --offline\n"));
assert!(
extra.contains("\tinstall -Dm755 target/release/mytool debian/mytool/usr/bin/mytool")
);
assert!(extra.contains("override_dh_auto_test:\n\tcargo test --release --offline\n"));
assert!(extra.contains("override_dh_auto_clean:\n\tcargo clean"));
assert!(!extra.contains("--locked"));
}
#[test]
fn rules_use_locked_only_with_lockfile() {
let template = super::super::get(TemplateId::Rust).unwrap();
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
let with_lock = opts(SourceDir::Path(dir.path().to_path_buf()));
assert!(!lockfile_present(&with_lock));
let extra = template.rules_extra(&with_lock);
assert!(!extra.contains("--locked"), "{extra}");
std::fs::write(dir.path().join("Cargo.lock"), "# generated\n").unwrap();
assert!(lockfile_present(&with_lock));
let extra = template.rules_extra(&with_lock);
assert!(extra.contains("\tcargo build --release --offline --locked\n"));
assert!(extra.contains("\tcargo test --release --offline --locked\n"));
}
/// Package names may carry `+`/`.` (legal dpkg, rejected by cargo): the
/// skeleton crate name is sanitized, and the install override picks the
/// crate-named artifact and installs it under the command name.
#[test]
fn skeleton_sanitizes_the_crate_name() {
let o = NewOptions {
name: "my.tool+".into(),
command: "mytool".into(),
..opts(SourceDir::Skeleton)
};
let template = super::super::get(TemplateId::Rust).unwrap();
let skeleton = template.skeleton(&o);
let cargo_toml = skeleton
.iter()
.find(|f| f.path == "Cargo.toml")
.expect("Cargo.toml skeleton");
assert!(cargo_toml.contents.contains("name = \"my_tool_\""));
let extra = template.rules_extra(&o);
assert!(
extra.contains(
"\tinstall -Dm755 target/release/my_tool_ debian/my.tool+/usr/bin/mytool\n"
),
"{extra}"
);
}
#[test]
fn probe_reads_cargo_toml_lines() {
let template = super::super::get(TemplateId::Rust).unwrap();
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"# comment\n\
[package]\n\
name = \"mytool\"\n\
version = \"2.3.4\"\n\
description = \"A cargo tool\"\n\
homepage = \"https://example.com/mytool\"\n\
license = \"MIT OR Apache-2.0\"\n\
\n\
[dependencies]\n\
serde = \"1\"\n",
)
.unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("mytool"));
assert_eq!(probe.version.as_deref(), Some("2.3.4"));
assert_eq!(probe.description.as_deref(), Some("A cargo tool"));
assert_eq!(
probe.homepage.as_deref(),
Some("https://example.com/mytool")
);
assert_eq!(probe.license.as_deref(), Some("MIT OR Apache-2.0"));
// Only section headers and comments: nothing to report.
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[dependencies]\n").unwrap();
assert!(template.probe(dir.path()).is_none());
}
/// With host cargo available, `cargo metadata` wins and yields the bin
/// target as the command. (Without cargo on PATH the line-parse fallback
/// above is exercised.)
#[test]
fn probe_prefers_cargo_metadata() {
if find_on_path("cargo").is_none() {
// No cargo on this host: metadata probing is silent and the
// fallback applies (already covered by the line-parse test).
return;
}
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"metaprobe\"\nversion = \"0.9.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").unwrap();
let template = super::super::get(TemplateId::Rust).unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("metaprobe"));
assert_eq!(probe.version.as_deref(), Some("0.9.0"));
assert_eq!(probe.command.as_deref(), Some("metaprobe"));
}
/// The vendoring hook over a zero-dependency skeleton: offline config
/// written, no failure (needs host cargo; without it the warning path
/// keeps the tree intact).
#[test]
fn post_write_vendors_skeleton() {
let dir = tempdir().unwrap();
let o = opts(SourceDir::Skeleton);
let template = super::super::get(TemplateId::Rust).unwrap();
for file in template.skeleton(&o) {
let path = dir.path().join(&file.path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, file.contents).unwrap();
}
template.post_write(&o, dir.path()).unwrap();
if find_on_path("cargo").is_some() {
let config = std::fs::read_to_string(dir.path().join(".cargo/config.toml")).unwrap();
assert!(config.contains("[source.crates-io]"), "{config}");
assert!(config.contains("replace-with = \"vendored-sources\""));
assert!(config.contains("[net]\noffline = true"), "{config}");
}
// An existing .cargo/config.toml is never overwritten.
let existing = dir.path().join(".cargo/config.toml");
if existing.exists() {
template.post_write(&o, dir.path()).unwrap();
let config = std::fs::read_to_string(&existing).unwrap();
assert!(config.contains("[source.crates-io]"));
}
}
}
+49 -4
View File
@@ -1,8 +1,10 @@
//! The `shell` template: a single interpreted script installed to
//! `/usr/bin` with plain `dh $@` plumbing.
use super::{OutputFile, Template};
use crate::new::options::{NewOptions, SourceDir};
use std::path::Path;
use super::{OutputFile, ProbeResult, Template};
use crate::new::options::{self, NewOptions, SourceDir};
/// Shell script / single interpreted file.
pub struct Shell;
@@ -39,12 +41,26 @@ impl Template for Shell {
format!("{}.sh usr/bin/{}\n", opts.command, opts.command),
)]
}
/// The file name of the single top-level script (sanitized) pre-fills the
/// package name and command questions.
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
let path = crate::new::detect::single_script(dir)?;
let stem = path.file_stem()?.to_str()?;
let name = options::sanitize_name(stem)?;
Some(ProbeResult {
command: Some(name.clone()),
name: Some(name),
..Default::default()
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new::options::{License, SourceDir, TemplateId};
use tempfile::tempdir;
fn opts() -> NewOptions {
NewOptions {
@@ -65,6 +81,9 @@ mod tests {
depends: Vec::new(),
native: false,
git: true,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
@@ -73,9 +92,9 @@ mod tests {
let o = opts();
let template = super::super::get(TemplateId::Shell).unwrap();
assert_eq!(template.architecture(), "all");
assert_eq!(template.architecture(&o), "all");
assert!(template.build_depends(&o).is_empty());
assert!(template.rules_extra().is_empty());
assert!(template.rules_extra(&o).is_empty());
let skeleton = template.skeleton(&o);
assert_eq!(skeleton.len(), 1);
@@ -88,4 +107,30 @@ mod tests {
assert_eq!(debian[0].path, "debian/install");
assert_eq!(debian[0].contents, "mytool.sh usr/bin/mytool\n");
}
#[test]
fn shell_probe_reads_script_file_name() {
let template = super::super::get(TemplateId::Shell).unwrap();
// The .sh extension is stripped, the stem sanitized into a package
// name and command.
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("My Tool.sh"), "#!/bin/sh\n").unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("my-tool"));
assert_eq!(probe.command.as_deref(), Some("my-tool"));
// A shebang file without extension works too.
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("runtool"), "#!/usr/bin/env bash\n").unwrap();
let probe = template.probe(dir.path()).expect("probe result");
assert_eq!(probe.name.as_deref(), Some("runtool"));
// Zero or several scripts: silent None.
let dir = tempdir().unwrap();
assert!(template.probe(dir.path()).is_none());
std::fs::write(dir.path().join("a.sh"), "#!/bin/sh\n").unwrap();
std::fs::write(dir.path().join("b.sh"), "#!/bin/sh\n").unwrap();
assert!(template.probe(dir.path()).is_none());
}
}
+3
View File
@@ -117,6 +117,9 @@ mod tests {
depends: Vec::new(),
native: false,
git: false,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}