Compare commits

..
6 Commits
24 changed files with 8010 additions and 166 deletions
+7 -1
View File
@@ -259,7 +259,13 @@ fn prepend_to_file(path: &Path, content: &str) -> Result<(), Box<dyn std::error:
Ok(()) Ok(())
} }
fn get_maintainer_info() -> Result<(String, String), Box<dyn std::error::Error>> { /// Discover the maintainer identity for changelog entries and package
/// scaffolding: `$DEBFULLNAME`/`$DEBEMAIL` when both are set, else the git
/// configuration (`user.name`/`user.email`).
///
/// Returns `(name, email)`, with a pointed error telling the user how to
/// configure the missing piece.
pub fn get_maintainer_info() -> Result<(String, String), Box<dyn std::error::Error>> {
// From environment variables // From environment variables
if let (Ok(name), Ok(email)) = (std::env::var("DEBFULLNAME"), std::env::var("DEBEMAIL")) { if let (Ok(name), Ok(email)) = (std::env::var("DEBFULLNAME"), std::env::var("DEBEMAIL")) {
return Ok((name, email)); return Ok((name, email));
+12
View File
@@ -83,8 +83,20 @@ async fn build_binary_package_impl(
let changelog_path = cwd.join("debian/changelog"); let changelog_path = cwd.join("debian/changelog");
let (package, version, package_series) = let (package, version, package_series) =
crate::changelog::parse_changelog_header(&changelog_path)?; crate::changelog::parse_changelog_header(&changelog_path)?;
// UNRELEASED is not a real archive series: without an explicit --series,
// build against the development series of the host vendor's distribution
// instead. An explicit --series always wins.
let resolved_series;
let series = if let Some(s) = series { let series = if let Some(s) = series {
s s
} else if crate::distro_info::is_unreleased(&package_series) {
let dist = crate::build::env::current_vendor();
resolved_series = crate::distro_info::effective_series(&package_series, &dist).await?;
log::info!(
"Changelog is UNRELEASED, building against series {}",
resolved_series
);
resolved_series.as_str()
} else { } else {
&package_series &package_series
}; };
+95
View File
@@ -169,6 +169,16 @@ pub fn supported_dists() -> Vec<String> {
DATA.dist.keys().cloned().collect() DATA.dist.keys().cloned().collect()
} }
/// Special changelog distribution marking an entry that has not been
/// released to any archive series yet
pub const UNRELEASED: &str = "UNRELEASED";
/// Whether `series` is the special [`UNRELEASED`] distribution rather than
/// a real archive series
pub fn is_unreleased(series: &str) -> bool {
series == UNRELEASED
}
/// Get time-ordered list of series information for a distribution, development series first /// Get time-ordered list of series information for a distribution, development series first
pub async fn get_ordered_series(dist: &str) -> Result<Vec<SeriesInformation>, Box<dyn Error>> { pub async fn get_ordered_series(dist: &str) -> Result<Vec<SeriesInformation>, Box<dyn Error>> {
let dist_data = DATA.dist.get(dist).ok_or_else(|| { let dist_data = DATA.dist.get(dist).ok_or_else(|| {
@@ -206,6 +216,30 @@ pub async fn get_ordered_series_name(dist: &str) -> Result<Vec<String>, Box<dyn
Ok(series.iter().map(|info| info.series.clone()).collect()) Ok(series.iter().map(|info| info.series.clone()).collect())
} }
/// The series to actually target when the changelog says [`UNRELEASED`]:
/// the development series of `dist`, i.e. the first entry of
/// [`get_ordered_series_name`] (which is documented "development series
/// first"). UNRELEASED work conventionally targets the next release, not
/// the last stable one. `dist` is matched case-insensitively, so vendor
/// names with original casing (dpkg's `Vendor:` field is e.g. "Ubuntu")
/// are accepted as-is. Any other `series` is returned unchanged. Errors
/// when `dist` is unknown or has no series list.
pub async fn effective_series(series: &str, dist: &str) -> Result<String, Box<dyn Error>> {
if !is_unreleased(series) {
return Ok(series.to_string());
}
// The series data keys are lowercase, unlike the vendor names that
// callers typically resolve from dpkg
let dist = dist.to_lowercase();
get_ordered_series_name(&dist)
.await?
.into_iter()
.next()
.ok_or_else(|| format!("Distribution '{dist}' has no series to target").into())
}
/// Get the latest released series for a dist (excluding future releases and special cases like sid) /// Get the latest released series for a dist (excluding future releases and special cases like sid)
pub async fn get_latest_released_series(dist: &str) -> Result<String, Box<dyn Error>> { pub async fn get_latest_released_series(dist: &str) -> Result<String, Box<dyn Error>> {
let latest = get_n_latest_released_series(dist, 1).await?; let latest = get_n_latest_released_series(dist, 1).await?;
@@ -523,6 +557,67 @@ mod tests {
); );
} }
#[test]
fn test_is_unreleased() {
// Matching is exact: UNRELEASED is uppercase by Debian convention
assert!(is_unreleased("UNRELEASED"));
assert!(!is_unreleased("unreleased"));
assert!(!is_unreleased("noble"));
assert!(!is_unreleased(""));
}
#[tokio::test]
async fn test_effective_series_passthrough() {
// A real series is returned unchanged, and the dist is not even
// looked up (an unknown dist only matters for UNRELEASED)
assert_eq!(effective_series("noble", "ubuntu").await.unwrap(), "noble");
assert_eq!(effective_series("sid", "debian").await.unwrap(), "sid");
assert_eq!(
effective_series("noble", "unknown-distro").await.unwrap(),
"noble"
);
}
#[tokio::test]
async fn test_effective_series_unreleased() {
// UNRELEASED resolves to the development series of the dist, i.e.
// the first entry of the time-ordered list. On current distro-info
// data this is the next Ubuntu release, while Debian's list starts
// with 'experimental' (sid comes second), so assert against the
// data itself rather than a hardcoded name.
for dist in ["ubuntu", "debian"] {
let ordered = get_ordered_series_name(dist).await.unwrap();
let resolved = effective_series(UNRELEASED, dist).await.unwrap();
assert_eq!(resolved, ordered[0]);
assert_ne!(resolved, UNRELEASED);
}
}
#[tokio::test]
async fn test_effective_series_unreleased_dist_case_insensitive() {
// Distro data keys are lowercase but dpkg vendors keep original
// casing ("Ubuntu"): the UNRELEASED lookup must resolve both
let expected = effective_series(UNRELEASED, "ubuntu").await.unwrap();
assert_eq!(
effective_series(UNRELEASED, "Ubuntu").await.unwrap(),
expected
);
assert_eq!(
effective_series(UNRELEASED, "UBUNTU").await.unwrap(),
expected
);
}
#[tokio::test]
async fn test_effective_series_unknown_dist() {
// UNRELEASED on an unknown distribution cannot be resolved
assert!(
effective_series(UNRELEASED, "unknown-distro")
.await
.is_err()
);
}
#[tokio::test] #[tokio::test]
async fn test_get_debian_series() { async fn test_get_debian_series() {
let series = get_ordered_series_name("debian").await.unwrap(); let series = get_ordered_series_name("debian").await.unwrap();
+2
View File
@@ -16,6 +16,8 @@ pub mod deb;
pub mod debian; pub mod debian;
/// Obtain general information about distribution, series, etc /// Obtain general information about distribution, series, etc
pub mod distro_info; pub mod distro_info;
/// Scaffold a new Debian source package (`pkh new`)
pub mod new;
/// Obtain information about one or multiple packages /// Obtain information about one or multiple packages
pub mod package_info; pub mod package_info;
/// Prune residual pkh build artifacts and caches /// Prune residual pkh build artifacts and caches
+186 -2
View File
@@ -35,6 +35,105 @@ fn main() {
let matches = command!() let matches = command!()
.subcommand_required(true) .subcommand_required(true)
.disable_version_flag(true) .disable_version_flag(true)
.subcommand(
Command::new("new")
.about("Scaffold a new Debian source package (buildable right away)")
.arg(arg!([name] "Package name: creates ./<name>/ with a fresh project skeleton. Without it (or with --source), the given/current directory is packaged"))
// NOTE: hyphenated long names are defined via the builder API
// because clap's `arg!` macro mis-tokenizes them (see the
// prune subcommand note below).
.arg(
clap::Arg::new("lang")
.long("lang")
.value_name("LANG")
.help("Language/build system: rust, python, meson, cmake, autotools, go, shell, makefile or empty"),
)
.arg(
clap::Arg::new("source")
.long("source")
.value_name("PATH")
.conflicts_with("name")
.help("Package the sources in PATH instead of creating ./<name>/"),
)
.arg(
clap::Arg::new("upstream_version")
.long("upstream-version")
.value_name("VERSION")
.help("Upstream version (default: 0.1.0)"),
)
.arg(
clap::Arg::new("revision")
.long("revision")
.value_name("N")
.value_parser(clap::value_parser!(u32))
.help("Debian revision (default: 1)"),
)
.arg(
clap::Arg::new("description")
.long("description")
.value_name("DESC")
.help("One-line package description"),
)
.arg(
clap::Arg::new("homepage")
.long("homepage")
.value_name("URL")
.help("Upstream homepage (http:// or https://)"),
)
.arg(
clap::Arg::new("license")
.long("license")
.value_name("SPDX")
.help("Upstream license (SPDX identifier, e.g. MIT, GPL-3.0+)"),
)
.arg(
clap::Arg::new("command")
.long("command")
.value_name("CMD")
.help("Installed command name (default: the package name)"),
)
.arg(
clap::Arg::new("maintainer")
.long("maintainer")
.value_name("NAME <EMAIL>")
.help("Maintainer (default: DEBFULLNAME/DEBEMAIL, then git config)"),
)
.arg(
clap::Arg::new("depends")
.long("depends")
.value_name("LIST")
.action(clap::ArgAction::Append)
.long_help("Runtime Depends of the metapackage flavor ('empty' template), as a comma-separated list (e.g. \"hello, hello-data (>= 1.0)\"). Can be specified multiple times.")
.help("Metapackage Depends list, comma-separated ('empty' template only)"),
)
.arg(
clap::Arg::new("dist")
.long("dist")
.value_name("DIST")
.help("Target distribution: debian or ubuntu (default: current vendor)"),
)
.arg(
clap::Arg::new("series")
.long("series")
.value_name("SERIES")
.help("Target series (default: the development series of --dist)"),
)
.arg(arg!(--release "Write the --series into debian/changelog instead of UNRELEASED").required(false))
.arg(arg!(--native "Use the 3.0 (native) source format (no orig tarball)").required(false))
.arg(
clap::Arg::new("no_git")
.long("no-git")
.action(clap::ArgAction::SetTrue)
.help("Do not initialize a git repository (.gitignore files are written anyway)"),
)
.arg(
clap::Arg::new("no_verify")
.long("no-verify")
.action(clap::ArgAction::SetTrue)
.help("Skip the post-scaffold build verification (the structural self-checks always run)"),
)
.arg(arg!(--defaults "Take the default answer for every question left unanswered (the package name is still required)").required(false)),
)
.subcommand( .subcommand(
Command::new("pull") Command::new("pull")
.about("Pull a source package from the archive or git") .about("Pull a source package from the archive or git")
@@ -134,6 +233,64 @@ fn main() {
.get_matches(); .get_matches();
match matches.subcommand() { match matches.subcommand() {
Some(("new", sub_matches)) => {
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(),
source: sub_matches
.get_one::<String>("source")
.map(std::path::PathBuf::from),
upstream_version: sub_matches.get_one::<String>("upstream_version").cloned(),
revision: sub_matches.get_one::<u32>("revision").copied(),
description: sub_matches.get_one::<String>("description").cloned(),
homepage: sub_matches.get_one::<String>("homepage").cloned(),
license: sub_matches.get_one::<String>("license").cloned(),
command: sub_matches.get_one::<String>("command").cloned(),
maintainer: sub_matches.get_one::<String>("maintainer").cloned(),
depends,
dist: sub_matches.get_one::<String>("dist").cloned(),
series: sub_matches.get_one::<String>("series").cloned(),
release: sub_matches
.get_one::<bool>("release")
.copied()
.unwrap_or(false),
native: sub_matches
.get_one::<bool>("native")
.copied()
.unwrap_or(false),
git: !sub_matches
.get_one::<bool>("no_git")
.copied()
.unwrap_or(false),
defaults: sub_matches
.get_one::<bool>("defaults")
.copied()
.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::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);
}
}
Some(("pull", sub_matches)) => { Some(("pull", sub_matches)) => {
let package = sub_matches.get_one::<String>("package").expect("required"); let package = sub_matches.get_one::<String>("package").expect("required");
let series = sub_matches.get_one::<String>("series").map(|s| s.as_str()); let series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
@@ -198,14 +355,41 @@ fn main() {
let changelog_path = cwd.join("debian/changelog"); let changelog_path = cwd.join("debian/changelog");
match pkh::changelog::parse_changelog_header(&changelog_path) { match pkh::changelog::parse_changelog_header(&changelog_path) {
Ok((_pkg, _ver, current_series)) => { Ok((_pkg, _ver, current_series)) => {
// Try to get the list of available series for this distribution // UNRELEASED is not a real series: offer it as a
// pinned first entry (selecting it keeps the changelog
// unreleased) on top of the current vendor's series
// list, defaulting to the development series. Any
// other series resolves through the series list of
// its own distribution.
match rt.block_on(async { match rt.block_on(async {
if pkh::distro_info::is_unreleased(&current_series) {
// Vendors keep original casing ("Ubuntu"),
// while the series data keys are lowercase
let dist = pkh::build::env::current_vendor().to_lowercase();
let mut series_list =
vec![pkh::distro_info::UNRELEASED.to_string()];
series_list.extend(
pkh::distro_info::get_ordered_series_name(&dist).await?,
);
Ok(series_list)
} else {
let dist = let dist =
pkh::distro_info::get_dist_from_series(&current_series).await?; pkh::distro_info::get_dist_from_series(&current_series).await?;
pkh::distro_info::get_ordered_series_name(&dist).await pkh::distro_info::get_ordered_series_name(&dist).await
}
}) { }) {
Ok(series_list) => { Ok(series_list) => {
match pkh::ui::select_series(&series_list, &current_series) { // Default to the development series (the
// first real entry) when the changelog is
// UNRELEASED, not to the pinned entry itself
let default = if pkh::distro_info::is_unreleased(&current_series)
&& series_list.len() > 1
{
series_list[1].clone()
} else {
current_series.clone()
};
match pkh::ui::select_series(&series_list, &default) {
Ok(selected) => Some(selected), Ok(selected) => Some(selected),
Err(e) => { Err(e) => {
error!( error!(
+812
View File
@@ -0,0 +1,812 @@
//! Generators for the common `debian/` files of a scaffolded package, plus
//! the orig tarball creation.
//!
//! Everything here renders in memory as [`OutputFile`]s; the caller writes
//! them all-or-nothing after checking for collisions (see
//! [`super::scaffold`]).
use std::collections::HashSet;
use std::path::Path;
use chrono::Datelike;
use tar::Builder;
use xz2::write::XzEncoder;
use super::options::NewOptions;
use super::templates::{OutputFile, Template};
/// `3.0 (quilt)` source format, the pkh new default.
pub const SOURCE_FORMAT_QUILT: &str = "3.0 (quilt)";
/// `3.0 (native)` source format, selected by `--native`.
pub const SOURCE_FORMAT_NATIVE: &str = "3.0 (native)";
/// The three source formats pkh knows how to build.
pub const KNOWN_SOURCE_FORMATS: [&str; 3] = [SOURCE_FORMAT_QUILT, SOURCE_FORMAT_NATIVE, "1.0"];
/// Directory and file names excluded from the orig tarball, at any depth of
/// the tree.
const ORIG_EXCLUDE: &[&str] = &[
".git",
"debian",
"target",
"node_modules",
"__pycache__",
".venv",
];
/// Path of the orig tarball for `name`/`upstream_version` next to `tree`.
pub fn orig_tarball_path(
tree: &Path,
name: &str,
upstream_version: &str,
) -> Option<std::path::PathBuf> {
tree.parent()
.map(|parent| parent.join(format!("{name}_{upstream_version}.orig.tar.xz")))
}
/// Render every common `debian/` file of the package.
pub fn files(opts: &NewOptions, template: &dyn Template) -> Vec<OutputFile> {
let mut files = vec![
source_format(opts),
changelog(opts),
control(opts, 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 {
OutputFile::new(
"debian/source/format",
format!(
"{}\n",
if opts.native {
SOURCE_FORMAT_NATIVE
} else {
SOURCE_FORMAT_QUILT
}
),
)
}
/// `debian/source/local-options` with `single-debian-patch`, so later
/// upstream-tree edits stay representable as one `debian/patches/debian-changes-*`
/// patch instead of failing the build (quilt only).
fn local_options() -> OutputFile {
OutputFile::new("debian/source/local-options", "single-debian-patch\n")
}
/// `debian/changelog`: the single initial entry, distribution UNRELEASED by
/// default (the dh_make convention: a fresh package is by definition not
/// ready for upload, and pkh skips signing for UNRELEASED), or the target
/// series with `--release`.
fn changelog(opts: &NewOptions) -> OutputFile {
let distribution = if opts.release {
opts.series.as_str()
} else {
crate::distro_info::UNRELEASED
};
let date = chrono::Local::now().format("%a, %d %b %Y %H:%M:%S %z");
OutputFile::new(
"debian/changelog",
format!(
"{name} ({version}) {distribution}; urgency=medium\n\
\n\
\x20 * Initial release.\n\
\n\
\x20-- {maintainer_name} <{maintainer_email}> {date}\n",
name = opts.name,
version = opts.full_version(),
distribution = distribution,
maintainer_name = opts.maintainer.0,
maintainer_email = opts.maintainer.1,
date = date,
),
)
}
/// Render a field whose values continue one per line (RFC822 continuation,
/// one leading space, commas between values, first value on the field line):
///
/// ```text
/// Build-Depends: debhelper-compat (= 13),
/// python3-all
/// ```
fn render_field(name: &str, values: &[String]) -> String {
let last = values.len() - 1;
let mut out = format!("{}: {}", name, values[0]);
if last > 0 {
out.push(',');
}
out.push('\n');
for (i, value) in values.iter().enumerate().skip(1) {
out.push_str(&format!(" {value}"));
if i != last {
out.push(',');
}
out.push('\n');
}
out
}
/// Render a free-text field body (long description, license paragraphs):
/// every line as a continuation, blank lines as ` .` (the deb822 encoding).
fn render_continuation_text(text: &str) -> String {
let mut out = String::new();
for line in text.lines() {
if line.trim().is_empty() {
out.push_str(" .\n");
} else {
out.push_str(&format!(" {line}\n"));
}
}
out
}
/// `debian/control`: one source stanza plus one binary stanza.
///
/// The binary package name is the source package name, the architecture
/// comes from the template (`all` for shell/empty), and a non-empty
/// `opts.depends` (the empty/metapackage flavor) lands in the binary
/// stanza's `Depends` field.
fn control(opts: &NewOptions, template: &dyn Template) -> OutputFile {
let mut control = String::new();
// Source stanza.
control.push_str(&format!("Source: {}\n", opts.name));
control.push_str("Section: utils\n");
control.push_str("Priority: optional\n");
control.push_str(&format!(
"Maintainer: {} <{}>\n",
opts.maintainer.0, opts.maintainer.1
));
control.push_str("Rules-Requires-Root: no\n");
let mut build_depends = vec!["debhelper-compat (= 13)".to_string()];
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"));
}
control.push('\n');
// Binary stanza.
control.push_str(&format!("Package: {}\n", opts.name));
control.push_str(&format!("Architecture: {}\n", template.architecture(opts)));
if !opts.depends.is_empty() {
control.push_str(&render_field("Depends", &opts.depends));
}
control.push_str(&format!("Description: {}\n", opts.summary));
control.push_str(&render_continuation_text(&opts.long_description));
OutputFile::new("debian/control", control)
}
/// `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);
if !contents.ends_with('\n') {
contents.push('\n');
}
}
OutputFile::executable("debian/rules", contents)
}
/// Short license-reference paragraph embedded in `debian/copyright`.
fn license_reference_paragraph(license: &super::options::License) -> String {
use super::options::License;
match license {
License::Custom(s) if s.eq_ignore_ascii_case("unknown") => {
"The licensing terms of this package are not known yet. \
Replace this paragraph with a proper license reference."
.to_string()
}
License::Custom(s) => format!(
"The package is distributed under the terms of the '{s}' license. \
Replace this paragraph with the full license reference."
),
known => format!(
"The package is distributed under the terms of the {} license. \
The full license text is available at <{}>.",
known.spdx(),
known.spdx_url()
),
}
}
/// `debian/copyright` in the DEP-5 machine-readable format: header, the
/// `Files: *` stanza covering the current year, and a standalone license
/// stanza with a short reference paragraph.
fn copyright(opts: &NewOptions) -> OutputFile {
let year = chrono::Local::now().year();
let mut out = String::new();
out.push_str("Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n");
out.push_str(&format!("Upstream-Name: {}\n", opts.name));
if let Some(homepage) = &opts.homepage {
out.push_str(&format!("Source: {homepage}\n"));
}
out.push('\n');
out.push_str("Files: *\n");
out.push_str(&format!(
"Copyright: {} {} <{}>\n",
year, opts.maintainer.0, opts.maintainer.1
));
out.push_str(&format!("License: {}\n", opts.license.spdx()));
out.push_str(&render_continuation_text(&license_reference_paragraph(
&opts.license,
)));
out.push('\n');
out.push_str(&format!("License: {}\n", opts.license.spdx()));
out.push_str(&render_continuation_text(&license_reference_paragraph(
&opts.license,
)));
OutputFile::new("debian/copyright", out)
}
/// `debian/.gitignore`: the debhelper build artifacts.
fn debian_gitignore(opts: &NewOptions) -> OutputFile {
OutputFile::new(
"debian/.gitignore",
format!(
"debian/files\n\
debian/.debhelper/\n\
debian/*.log\n\
debian/{}/\n\
debian/debhelper-build-stamp\n\
debian/*.substvars\n",
opts.name
),
)
}
/// Entries of the root `.gitignore` written in skeleton mode (build
/// artifacts, next to the tree).
pub const ROOT_GITIGNORE_ENTRIES: [&str; 6] = [
"*.deb",
"*.dsc",
"*.changes",
"*.buildinfo",
"*.tar.xz",
"target/",
];
/// Merge the root `.gitignore` entries into `existing` (the current file
/// contents, when there is one): missing entries are appended, an existing
/// file is never overwritten just to duplicate entries. Returns the new
/// contents, or `None` when nothing has to be written.
pub fn merge_root_gitignore(existing: Option<&str>) -> Option<String> {
let have: HashSet<&str> = existing
.map(|content| content.lines().map(str::trim).collect())
.unwrap_or_default();
let missing: Vec<&str> = ROOT_GITIGNORE_ENTRIES
.iter()
.copied()
.filter(|entry| !have.contains(entry))
.collect();
if missing.is_empty() {
return None;
}
let mut out = existing.unwrap_or("").to_string();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
// Section comment only for a fresh file; appending to a user file adds
// bare entries.
if existing.is_none() {
out.push_str("# pkh build artifacts\n");
}
for entry in missing {
out.push_str(entry);
out.push('\n');
}
Some(out)
}
/// Create `../<name>_<upstream_version>.orig.tar.xz` containing the tree,
/// excluding `debian/` and VCS/build directories, so the first
/// `dpkg-source -b` (quilt) succeeds immediately. Refuses to overwrite an
/// existing tarball.
pub fn create_orig_tarball(
tree: &Path,
name: &str,
upstream_version: &str,
) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
let tarball_path = orig_tarball_path(tree, name, upstream_version).ok_or_else(|| {
format!(
"cannot determine the parent directory of '{}'",
tree.display()
)
})?;
if tarball_path.exists() {
return Err(format!(
"'{}' already exists: pkh new refuses to overwrite it. \
Remove it first, or pass --native to skip the orig tarball.",
tarball_path.display()
)
.into());
}
let file = std::fs::File::create(&tarball_path)?;
let encoder = XzEncoder::new(file, 6);
let mut builder = Builder::new(encoder);
// Deterministic-ish ordering: sort entries by name at every level.
let prefix = format!("{name}-{upstream_version}");
// The single top-level directory dpkg-source expects.
builder.append_dir(&prefix, tree)?;
append_tree(&mut builder, tree, &prefix, 0)?;
builder
.finish()
.map_err(|e| format!("failed to write '{}': {}", tarball_path.display(), e))?;
log::info!(
"Created orig tarball {}",
crate::ui::display_path(&tarball_path)
);
Ok(tarball_path)
}
/// Recursively append `dir` to the archive under `archive_path`, skipping
/// the [`ORIG_EXCLUDE`] names and non-regular files.
fn append_tree(
builder: &mut Builder<XzEncoder<std::fs::File>>,
dir: &Path,
archive_path: &str,
depth: usize,
) -> Result<(), Box<dyn std::error::Error>> {
let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(dir)?.collect::<Result<_, _>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
let file_name = entry.file_name();
let name = file_name.to_string_lossy().into_owned();
if depth == 0 && name == "debian" {
continue;
}
if ORIG_EXCLUDE.contains(&name.as_str()) {
continue;
}
let entry_archive_path = format!("{archive_path}/{name}");
let metadata = std::fs::metadata(&path)
.map_err(|e| format!("cannot stat '{}': {}", path.display(), e))?;
if metadata.is_dir() {
builder.append_dir(&entry_archive_path, &path)?;
append_tree(builder, &path, &entry_archive_path, depth + 1)?;
} else if metadata.is_file() {
// The mode (including the exec bit) travels through the header.
let mut header = tar::Header::new_gnu();
header.set_metadata(&metadata);
header.set_size(metadata.len());
let file = std::fs::File::open(&path)
.map_err(|e| format!("cannot read '{}': {}", path.display(), e))?;
builder
.append_data(&mut header, &entry_archive_path, file)
.map_err(|e| format!("cannot add '{}' to the tarball: {}", path.display(), e))?;
} else {
// Sockets, fifos, devices have no business in an orig tarball.
log::warn!(
"Skipping non-regular file '{}' while creating the orig tarball",
path.display()
);
}
}
Ok(())
}
/// Write an in-memory file list to `tree`, creating parent directories and
/// applying the executable bit. Callers must have checked collisions first.
pub(crate) fn write_files(
tree: &Path,
files: &[OutputFile],
) -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt;
for file in files {
let path = tree.join(&file.path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, &file.contents)?;
if file.executable {
let mut permissions = std::fs::metadata(&path)?.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&path, permissions)?;
}
log::debug!("Wrote {}", path.display());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new::options::{License, SourceDir, TemplateId};
fn opts() -> NewOptions {
NewOptions {
name: "mytool".into(),
template: TemplateId::Shell,
source_dir: 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: Some("https://example.com/mytool".into()),
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 source_format_and_local_options() {
let o = opts();
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"))
};
assert_eq!(find("debian/source/format").contents, "3.0 (quilt)\n");
assert_eq!(
find("debian/source/local-options").contents,
"single-debian-patch\n"
);
let native = NewOptions {
native: true,
..opts()
};
let files = super::files(
&native,
crate::new::templates::get(TemplateId::Shell).unwrap(),
);
assert!(
files
.iter()
.all(|f| f.path != "debian/source/local-options")
);
assert_eq!(
files
.iter()
.find(|f| f.path == "debian/source/format")
.unwrap()
.contents,
"3.0 (native)\n"
);
}
#[test]
fn changelog_rendering_and_parse() {
let o = opts();
let changelog = super::changelog(&o);
assert_eq!(changelog.path, "debian/changelog");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("changelog");
std::fs::write(&path, &changelog.contents).unwrap();
let (source, version, distribution) =
crate::changelog::parse_changelog_header(&path).unwrap();
assert_eq!(source, "mytool");
assert_eq!(version, "0.1.0-1");
assert_eq!(distribution, "UNRELEASED");
// dpkg-style zero-padded RFC2822 date in the trailer.
assert!(
changelog
.contents
.contains(" -- Jane Doe <jane@example.com> ")
);
let date_line = changelog
.contents
.lines()
.find(|l| l.starts_with(" -- "))
.unwrap();
let date = date_line.rsplit_once(" ").unwrap().1;
// `%d` is zero-padded: positions 5-6 must be the two-digit day
// (e.g. "Tue, 05 Sep 2026 ...").
assert!(date[5..7].bytes().all(|b| b.is_ascii_digit()));
// --release writes the target series.
let released = NewOptions {
release: true,
..opts()
};
std::fs::write(&path, super::changelog(&released).contents).unwrap();
let (_, _, distribution) = crate::changelog::parse_changelog_header(&path).unwrap();
assert_eq!(distribution, "resolute");
}
#[test]
fn control_rendering_and_parse() {
let o = opts();
let control = super::control(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
// RFC822 continuation: first dep on the field line, the rest indented.
assert!(
control
.contents
.contains("Build-Depends: debhelper-compat (= 13)\n")
);
let parsed = crate::debian::ControlInfo::parse_content(&control.contents).unwrap();
assert_eq!(parsed.source_name(), "mytool");
assert_eq!(parsed.source.get("Section"), Some("utils"));
assert_eq!(parsed.source.get("Priority"), Some("optional"));
assert_eq!(parsed.source.get("Rules-Requires-Root"), Some("no"));
assert_eq!(
parsed.source.get("Homepage"),
Some("https://example.com/mytool")
);
assert_eq!(parsed.binaries.len(), 1);
assert_eq!(parsed.binaries[0].get("Package"), Some("mytool"));
assert_eq!(parsed.binaries[0].get("Architecture"), Some("all"));
assert_eq!(
parsed.binaries[0].get("Description"),
Some("A tool that does one thing well\nA tool that does one thing well")
);
// Without homepage both the control Homepage field and the DEP-5
// Source field are absent (the stanza's leading `Source:` line is
// still there of course).
let o = NewOptions {
homepage: None,
..opts()
};
let control = super::control(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
assert!(!control.contents.contains("Homepage:"));
let parsed = crate::debian::ControlInfo::parse_content(&control.contents).unwrap();
assert!(parsed.source.get("Homepage").is_none());
}
#[test]
fn rules_is_executable_minimal_makefile() {
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());
assert!(c.contents.starts_with(
"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n"
));
assert!(c.contents.contains("Upstream-Name: mytool\n"));
assert!(c.contents.contains("Source: https://example.com/mytool\n"));
assert!(c.contents.contains("Files: *\n"));
assert!(c.contents.contains("License: MIT\n"));
assert!(c.contents.contains(&format!(
"Copyright: {} Jane Doe <jane@example.com>\n",
chrono::Local::now().year()
)));
assert!(c.contents.contains("https://spdx.org/licenses/MIT.html"));
// Unknown license: honest reference paragraph, still valid deb822.
let o = NewOptions {
license: License::Custom("unknown".into()),
..opts()
};
let c = super::copyright(&o);
assert!(c.contents.contains("not known yet"));
assert!(crate::debian::parse_paragraphs(&c.contents).len() >= 3);
}
#[test]
fn debian_gitignore_contents() {
let g = super::debian_gitignore(&opts());
assert_eq!(
g.contents,
"debian/files\ndebian/.debhelper/\ndebian/*.log\ndebian/mytool/\n\
debian/debhelper-build-stamp\ndebian/*.substvars\n"
);
}
#[test]
fn root_gitignore_merge() {
// Fresh file: header + all entries.
let fresh = merge_root_gitignore(None).unwrap();
assert!(fresh.starts_with("# pkh build artifacts\n"));
for entry in ROOT_GITIGNORE_ENTRIES {
assert!(fresh.contains(entry), "{entry} missing");
}
// Existing file: only the missing entries are appended, nothing lost.
let existing = "*.deb\nnode_modules/\n";
let merged = merge_root_gitignore(Some(existing)).unwrap();
assert!(merged.starts_with(existing));
assert!(merged.contains("*.dsc\n"));
assert!(!merged.contains("*.deb\n*.deb"));
// Everything already there: nothing to write.
let full: String = ROOT_GITIGNORE_ENTRIES
.iter()
.map(|e| format!("{e}\n"))
.collect();
assert!(merge_root_gitignore(Some(&full)).is_none());
}
#[test]
fn orig_tarball_layout() {
let dir = tempfile::tempdir().unwrap();
let tree = dir.path().join("mytool");
std::fs::create_dir_all(tree.join("debian")).unwrap();
std::fs::create_dir_all(tree.join("target")).unwrap();
std::fs::create_dir_all(tree.join("src/nested")).unwrap();
std::fs::write(tree.join("debian/control"), "control").unwrap();
std::fs::write(tree.join("target/artifact"), "junk").unwrap();
std::fs::write(tree.join("src/nested/code.txt"), "code").unwrap();
let tarball = create_orig_tarball(&tree, "mytool", "0.1.0").unwrap();
assert_eq!(tarball, dir.path().join("mytool_0.1.0.orig.tar.xz"));
assert!(tarball.exists());
let file = std::fs::File::open(&tarball).unwrap();
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(file));
let mut names: Vec<String> = archive
.entries()
.unwrap()
.map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
.collect();
// The tree prefix and the nested file are there...
assert!(
names
.iter()
.any(|n| n.trim_end_matches('/') == "mytool-0.1.0")
);
assert!(
names
.iter()
.any(|n| n == "mytool-0.1.0/src/nested/code.txt")
);
// ...but debian/, target/ and other excluded names are not.
assert!(!names.iter().any(|n| n.contains("debian")));
assert!(!names.iter().any(|n| n.contains("target")));
names.sort();
}
#[test]
fn orig_tarball_refuses_overwrite() {
let dir = tempfile::tempdir().unwrap();
let tree = dir.path().join("mytool");
std::fs::create_dir_all(&tree).unwrap();
std::fs::write(dir.path().join("mytool_0.1.0.orig.tar.xz"), b"existing").unwrap();
let err = create_orig_tarball(&tree, "mytool", "0.1.0").unwrap_err();
assert!(err.to_string().contains("already exists"));
}
#[test]
fn write_files_sets_exec_bit_and_parents() {
let dir = tempfile::tempdir().unwrap();
let files = vec![
OutputFile::new("a/b/c.txt", "deep"),
OutputFile::executable("debian/rules", "#!/usr/bin/make -f\n"),
];
write_files(dir.path(), &files).unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("a/b/c.txt")).unwrap(),
"deep"
);
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join("debian/rules"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o755);
}
}
+397
View File
@@ -0,0 +1,397 @@
//! Project detection for `pkh new`: which template matches an existing
//! source directory.
//!
//! The rule set is deliberately simple and table-driven (highest precedence
//! first):
//!
//! 1. well-known build-system marker files at the top level of the
//! directory (`Cargo.toml`, `pyproject.toml`/`setup.py`/`setup.cfg`,
//! `meson.build`, `CMakeLists.txt`, `configure.ac`, `go.mod`,
//! `Makefile`) — more than one distinct template matching is
//! [`Detection::Ambiguous`],
//! 2. otherwise, exactly one top-level script (a `*.sh` file, or a file
//! whose first line is a `#!` shebang) → [`TemplateId::Shell`],
//! several scripts or none → nothing,
//! 3. otherwise [`Detection::Empty`].
//!
//! Detection only looks at the top level on purpose: source files below
//! `src/` etc. carry no extra signal (a `src/main.rs` without `Cargo.toml`
//! is not a Rust project pkh can package), and recursion would turn stray
//! vendored files into false matches.
use std::path::Path;
use regex::Regex;
use super::options::TemplateId;
/// Outcome of the detection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Detection {
/// Exactly one template matches.
Single(TemplateId),
/// Several templates match; the caller must ask (wizard) or demand an
/// explicit `--lang`.
Ambiguous(Vec<TemplateId>),
/// Nothing recognized.
Empty,
}
/// Marker files per template, in precedence order (see the module docs).
const MARKERS: [(TemplateId, &[&str]); 7] = [
(TemplateId::Rust, &["Cargo.toml"]),
(
TemplateId::Python,
&["pyproject.toml", "setup.py", "setup.cfg"],
),
(TemplateId::Meson, &["meson.build"]),
(TemplateId::Cmake, &["CMakeLists.txt"]),
(TemplateId::Autotools, &["configure.ac"]),
(TemplateId::Go, &["go.mod"]),
(TemplateId::Makefile, &["Makefile"]),
];
/// Detect the template matching the project in `dir`.
pub fn detect(dir: &Path) -> Detection {
let mut hits: Vec<TemplateId> = Vec::new();
for (id, markers) in MARKERS {
if markers.iter().any(|marker| dir.join(marker).exists()) && !hits.contains(&id) {
hits.push(id);
}
}
match hits.as_slice() {
[] => {}
[only] => return Detection::Single(*only),
_ => return Detection::Ambiguous(hits),
}
if single_script(dir).is_some() {
Detection::Single(TemplateId::Shell)
} else {
Detection::Empty
}
}
/// 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.
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() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
// Hidden files and packaging leftovers carry no signal.
if name.starts_with('.') {
continue;
}
let is_script = name.ends_with(".sh") || has_shebang(&path);
if is_script {
if found.is_some() {
return None;
}
found = Some(path);
}
}
found
}
/// Whether the first line of the file starts with `#!`.
fn has_shebang(path: &Path) -> bool {
let Ok(content) = std::fs::read(path) else {
return false;
};
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::*;
use tempfile::tempdir;
fn touch(dir: &Path, name: &str) {
std::fs::write(dir.join(name), "x").unwrap();
}
#[test]
fn marker_files_map_to_templates() {
let cases = [
("Cargo.toml", TemplateId::Rust),
("pyproject.toml", TemplateId::Python),
("setup.py", TemplateId::Python),
("setup.cfg", TemplateId::Python),
("meson.build", TemplateId::Meson),
("CMakeLists.txt", TemplateId::Cmake),
("configure.ac", TemplateId::Autotools),
("go.mod", TemplateId::Go),
("Makefile", TemplateId::Makefile),
];
for (marker, expected) in cases {
let dir = tempdir().unwrap();
touch(dir.path(), marker);
assert_eq!(detect(dir.path()), Detection::Single(expected), "{marker}");
}
}
#[test]
fn multiple_markers_are_ambiguous() {
let dir = tempdir().unwrap();
touch(dir.path(), "Cargo.toml");
touch(dir.path(), "Makefile");
assert_eq!(
detect(dir.path()),
Detection::Ambiguous(vec![TemplateId::Rust, TemplateId::Makefile])
);
let dir = tempdir().unwrap();
touch(dir.path(), "pyproject.toml");
touch(dir.path(), "setup.py");
// Both markers map to the same template: one hit, not ambiguous.
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::Python));
let dir = tempdir().unwrap();
touch(dir.path(), "meson.build");
touch(dir.path(), "CMakeLists.txt");
assert_eq!(
detect(dir.path()),
Detection::Ambiguous(vec![TemplateId::Meson, TemplateId::Cmake])
);
}
#[test]
fn single_script_is_shell() {
// .sh extension.
let dir = tempdir().unwrap();
touch(dir.path(), "run.sh");
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::Shell));
// Shebang without extension.
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("run"), "#!/usr/bin/env python3\n").unwrap();
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::Shell));
// Two scripts: not exactly one, nothing recognized.
let dir = tempdir().unwrap();
touch(dir.path(), "a.sh");
touch(dir.path(), "b.sh");
assert_eq!(detect(dir.path()), Detection::Empty);
// Plain files without shebang are not scripts.
let dir = tempdir().unwrap();
touch(dir.path(), "README");
assert_eq!(detect(dir.path()), Detection::Empty);
}
#[test]
fn nothing_matches_is_empty() {
let dir = tempdir().unwrap();
assert_eq!(detect(dir.path()), Detection::Empty);
// Nonexistent directory: empty, not a panic.
let dir = tempdir().unwrap();
assert_eq!(detect(&dir.path().join("missing")), Detection::Empty);
}
#[test]
fn hidden_files_and_subdirs_are_ignored() {
let dir = tempdir().unwrap();
std::fs::create_dir(dir.path().join("subdir.sh")).unwrap();
std::fs::write(dir.path().join(".hidden.sh"), "#!/bin/sh\n").unwrap();
// 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);
}
}
+64
View File
@@ -0,0 +1,64 @@
//! Git handling for `pkh new`: initialize a repository in the scaffolded
//! tree unless it is already inside one (the `.gitignore`s are written
//! regardless).
use std::path::Path;
/// Ensure `dir` has a git repository when it should: when `dir` is already
/// inside a work tree (its own or a parent's), nothing is initialized and
/// `Ok(false)` is returned with an info log; otherwise a repository is
/// initialized in `dir` when `init` is set (the `--no-git` case passes
/// `init = false`).
pub fn ensure_repository(dir: &Path, init: bool) -> Result<bool, Box<dyn std::error::Error>> {
match git2::Repository::discover(dir) {
Ok(_) => {
log::info!(
"Already inside a git repository; skipping git init \
(the .gitignore files are written anyway)"
);
Ok(false)
}
Err(_) if init => {
git2::Repository::init(dir)?;
log::info!("Initialized empty git repository in {}", dir.display());
Ok(true)
}
// --no-git: gitignores only.
Err(_) => Ok(false),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn init_skipped_with_no_git() {
let dir = tempdir().unwrap();
assert!(!ensure_repository(dir.path(), false).unwrap());
assert!(!dir.path().join(".git").exists());
}
#[test]
fn init_creates_repository() {
let dir = tempdir().unwrap();
assert!(ensure_repository(dir.path(), true).unwrap());
assert!(dir.path().join(".git").exists());
// A second call discovers the fresh repository and skips init.
assert!(!ensure_repository(dir.path(), true).unwrap());
}
#[test]
fn parent_repository_is_discovered() {
let dir = tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir_all(&sub).unwrap();
git2::Repository::init(dir.path()).unwrap();
// The subdirectory is already inside the parent work tree.
assert!(!ensure_repository(&sub, true).unwrap());
assert!(!sub.join(".git").exists());
}
}
+716
View File
@@ -0,0 +1,716 @@
//! `pkh new`: interactive-first package scaffolding (see
//! `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), 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;
use std::error::Error;
use std::time::Duration;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use options::NewOptions;
use templates::OutputFile;
/// Scaffold a full Debian source tree from `opts`.
///
/// Steps, aborting early with a pointed error message:
/// 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. 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));
pb.set_style(
ProgressStyle::default_bar()
.template("> {spinner:.blue} {prefix}")
.unwrap(),
);
pb.set_prefix("Scaffolding");
let result = scaffold_steps(&opts, &pb);
// Clear the spinner whatever the outcome; errors are reported by the
// caller as plain log lines.
pb.finish_and_clear();
multi.remove(&pb);
if result.is_ok() {
print_success(&opts);
}
result
}
/// 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: 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 has no registered implementation. \
This is a pkh bug; please report it.",
opts.template,
)
})?;
// 2. Target directory checks.
let cwd = std::env::current_dir()?;
let target = opts.target_dir(&cwd);
if target.join("debian/control").exists() {
return Err(format!(
"'{}' already contains a debian/control file: pkh new refuses to \
touch an existing Debian packaging tree",
target.display()
)
.into());
}
match &opts.source_dir {
options::SourceDir::Skeleton => {
if target.exists() {
if !target.is_dir() {
return Err(
format!("'{}' exists and is not a directory", target.display()).into(),
);
}
if std::fs::read_dir(&target)?.next().is_some() {
return Err(format!(
"directory '{}' already exists and is not empty: \
pkh new refuses to scaffold into it",
target.display()
)
.into());
}
}
}
options::SourceDir::Here => {
// The cwd always exists.
}
options::SourceDir::Path(path) => {
if !path.is_dir() {
return Err(format!(
"source directory '{}' does not exist or is not a directory",
path.display()
)
.into());
}
}
}
// Fail before writing anything when the orig tarball already exists.
if !opts.native
&& let Some(tarball) =
debian::orig_tarball_path(&target, &opts.name, &opts.upstream_version_no_epoch())
&& tarball.exists()
{
return Err(format!(
"'{}' already exists: pkh new refuses to overwrite it. \
Remove it first, or pass --native to skip the orig tarball.",
tarball.display()
)
.into());
}
// 3. Render everything in memory, then check for collisions (within the
// generated set and against existing files).
pb.set_message("Rendering files");
let skeleton = matches!(opts.source_dir, options::SourceDir::Skeleton);
let mut files: Vec<OutputFile> = debian::files(opts, template);
if skeleton {
files.extend(template.skeleton(opts));
}
files.extend(template.debian(opts));
let paths: Vec<String> = files.iter().map(|f| f.path.clone()).collect();
options::check_file_collisions(&paths)?;
for path in &paths {
let existing = target.join(path);
if existing.exists() {
return Err(format!(
"refusing to overwrite existing file '{}'",
existing.display()
)
.into());
}
}
// 4. Write the files (all-or-nothing: nothing was written on any error
// above).
pb.set_message("Writing files");
debian::write_files(&target, &files)?;
// Root .gitignore: skeleton mode only, never overwriting an existing
// file (append the missing entries instead).
if skeleton
&& let Some(contents) = debian::merge_root_gitignore(
std::fs::read_to_string(target.join(".gitignore"))
.ok()
.as_deref(),
)
{
std::fs::write(target.join(".gitignore"), contents)?;
}
// 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())?;
}
// 7. Git.
pb.set_message("Initializing git");
git::ensure_repository(&target, opts.git)?;
// 8. Structural verification.
pb.set_message("Verifying");
verify::verify(&target)?;
Ok(())
}
/// The success message: what was created and the next steps.
fn print_success(opts: &NewOptions) {
let target =
opts.target_dir(&std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")));
// `display_path` yields an empty string when the target is the cwd
// itself (Here mode): show it as `.`.
let display = match crate::ui::display_path(&target) {
display if display.is_empty() => ".".to_string(),
display => display,
};
log::info!(
"Created {display} — {} ({}-{}) for {}/{}, template '{}'",
opts.name,
opts.upstream_version,
opts.revision,
opts.dist,
opts.series,
opts.template
);
log::info!("Next steps:");
log::info!(" cd {display}");
if opts.release {
log::info!(
" pkh chlog # for later changes; the entry already targets {}",
opts.series
);
} else {
log::info!(
" pkh chlog # releases the UNRELEASED entry to '{}' when ready",
opts.series
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new::options::{License, SourceDir, TemplateId};
use serial_test::serial;
use tempfile::tempdir;
fn opts(template: TemplateId, name: &str, source_dir: SourceDir) -> NewOptions {
NewOptions {
name: name.to_string(),
template,
source_dir,
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: name.to_string(),
maintainer: ("Jane Doe".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,
}
}
/// Run `scaffold` with the cwd changed to `dir` (restored afterwards);
/// must run under `#[serial]` because the cwd is process-global.
fn scaffold_in(dir: &std::path::Path, opts: NewOptions) -> Result<(), Box<dyn Error>> {
let previous = std::env::current_dir()?;
std::env::set_current_dir(dir)?;
let result = scaffold(opts, &MultiProgress::new());
std::env::set_current_dir(previous)?;
result
}
#[test]
#[serial]
fn scaffold_shell_skeleton_tree() {
let dir = tempdir().unwrap();
scaffold_in(
dir.path(),
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
)
.unwrap();
let tree = dir.path().join("mytool");
// Every expected file exists.
for path in [
"debian/control",
"debian/changelog",
"debian/rules",
"debian/copyright",
"debian/source/format",
"debian/source/local-options",
"debian/.gitignore",
"debian/install",
"mytool.sh",
".gitignore",
] {
assert!(tree.join(path).exists(), "{path} missing");
}
// rules and the script carry the exec bit.
use std::os::unix::fs::PermissionsExt;
for executable in ["debian/rules", "mytool.sh"] {
let mode = std::fs::metadata(tree.join(executable))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o755, "{executable}");
}
// control re-parses.
let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap();
assert_eq!(control.source_name(), "mytool");
assert_eq!(control.binaries[0].get("Architecture"), Some("all"));
assert_eq!(
control.source.get("Build-Depends"),
Some("debhelper-compat (= 13)")
);
assert!(control.binaries[0].get("Depends").is_none());
// changelog re-parses: UNRELEASED by default.
let (_, version, distribution) =
crate::changelog::parse_changelog_header(&tree.join("debian/changelog")).unwrap();
assert_eq!(version, "0.1.0-1");
assert_eq!(distribution, "UNRELEASED");
// source/format + local-options.
assert_eq!(
std::fs::read_to_string(tree.join("debian/source/format")).unwrap(),
"3.0 (quilt)\n"
);
assert_eq!(
std::fs::read_to_string(tree.join("debian/source/local-options")).unwrap(),
"single-debian-patch\n"
);
// install mapping.
assert_eq!(
std::fs::read_to_string(tree.join("debian/install")).unwrap(),
"mytool.sh usr/bin/mytool\n"
);
// Root .gitignore.
let gitignore = std::fs::read_to_string(tree.join(".gitignore")).unwrap();
assert!(gitignore.contains("*.deb"));
assert!(gitignore.contains("target/"));
// Orig tarball: contains the skeleton file, excludes debian/.
let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz");
assert!(tarball.exists());
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/mytool.sh"),
"{names:?}"
);
assert!(!names.iter().any(|n| n.contains("debian")), "{names:?}");
}
#[test]
#[serial]
fn scaffold_empty_base_and_metapackage_flavors() {
let dir = tempdir().unwrap();
// Metapackage flavor: non-empty depends.
let mut o = opts(TemplateId::Empty, "metapkg", SourceDir::Skeleton);
o.depends = vec!["hello".into(), "hello-data (>= 1.0)".into()];
scaffold_in(dir.path(), o).unwrap();
let tree = dir.path().join("metapkg");
let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap();
assert_eq!(
control.binaries[0].get("Depends"),
Some("hello,\nhello-data (>= 1.0)")
);
assert_eq!(control.binaries[0].get("Architecture"), Some("all"));
// No install file, no build-system skeleton: the README stub only.
assert!(!tree.join("debian/install").exists());
assert!(tree.join("README").exists());
// The tarball excludes debian/ but carries the README.
let tarball = dir.path().join("metapkg_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 == "metapkg-0.1.0/README"),
"{names:?}"
);
// Empty base flavor: no depends, no Depends field.
let dir = tempdir().unwrap();
scaffold_in(
dir.path(),
opts(TemplateId::Empty, "basepkg", SourceDir::Skeleton),
)
.unwrap();
let control =
crate::debian::ControlInfo::parse(&dir.path().join("basepkg/debian/control")).unwrap();
assert!(control.binaries[0].get("Depends").is_none());
}
#[test]
#[serial]
fn scaffold_release_targets_series() {
let dir = tempdir().unwrap();
let mut o = opts(TemplateId::Empty, "released", SourceDir::Skeleton);
o.release = true;
scaffold_in(dir.path(), o).unwrap();
let (_, _, distribution) =
crate::changelog::parse_changelog_header(&dir.path().join("released/debian/changelog"))
.unwrap();
assert_eq!(distribution, "resolute");
}
#[test]
#[serial]
fn scaffold_here_mode_packages_existing_dir() {
let dir = tempdir().unwrap();
// Here mode packages the cwd itself, so the orig tarball lands one
// level up (dpkg convention): package a subdirectory of the tempdir
// to keep the artifacts inside it.
let tree = dir.path().join("packdir");
std::fs::create_dir_all(&tree).unwrap();
std::fs::write(tree.join("run.sh"), "#!/bin/sh\necho hi\n").unwrap();
scaffold_in(&tree, opts(TemplateId::Shell, "runtool", SourceDir::Here)).unwrap();
// debian/ lands directly in the directory; no skeleton file, no
// root .gitignore (skeleton mode only), no debian/install (the
// generated one would reference the non-existent skeleton script),
// and the existing script is left alone.
assert!(tree.join("debian/control").exists());
assert!(!tree.join("runtool.sh").exists());
assert!(!tree.join(".gitignore").exists());
assert!(!tree.join("debian/install").exists());
assert_eq!(
std::fs::read_to_string(tree.join("run.sh")).unwrap(),
"#!/bin/sh\necho hi\n"
);
// The orig tarball carries the pre-existing script, next to the tree.
let tarball = dir.path().join("runtool_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 == "runtool-0.1.0/run.sh"),
"{names:?}"
);
}
#[test]
#[serial]
fn scaffold_refuses_existing_trees_and_artifacts() {
let dir = tempdir().unwrap();
// Existing debian/control.
let tree = dir.path().join("mytool");
std::fs::create_dir_all(tree.join("debian")).unwrap();
std::fs::write(tree.join("debian/control"), "Source: mytool\n").unwrap();
let err = scaffold_in(
dir.path(),
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
)
.unwrap_err();
assert!(err.to_string().contains("debian/control"), "{err}");
// Non-empty skeleton target.
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("mytool")).unwrap();
std::fs::write(dir.path().join("mytool/junk"), "x").unwrap();
let err = scaffold_in(
dir.path(),
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
)
.unwrap_err();
assert!(err.to_string().contains("not empty"), "{err}");
// Existing orig tarball: nothing gets written.
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("mytool_0.1.0.orig.tar.xz"), b"old").unwrap();
let err = scaffold_in(
dir.path(),
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
)
.unwrap_err();
assert!(err.to_string().contains("already exists"), "{err}");
assert!(!dir.path().join("mytool/debian/control").exists());
// Missing --source directory.
let dir = tempdir().unwrap();
let err = scaffold_in(
dir.path(),
opts(
TemplateId::Shell,
"mytool",
SourceDir::Path(dir.path().join("missing")),
),
)
.unwrap_err();
assert!(err.to_string().contains("does not exist"), "{err}");
}
#[test]
#[serial]
fn scaffold_native_skips_tarball_and_local_options() {
let dir = tempdir().unwrap();
let mut o = opts(TemplateId::Shell, "nativepkg", SourceDir::Skeleton);
o.native = true;
scaffold_in(dir.path(), o).unwrap();
let tree = dir.path().join("nativepkg");
assert!(!dir.path().join("nativepkg_0.1.0.orig.tar.xz").exists());
assert!(!tree.join("debian/source/local-options").exists());
assert_eq!(
std::fs::read_to_string(tree.join("debian/source/format")).unwrap(),
"3.0 (native)\n"
);
}
/// 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).
#[test]
#[serial]
fn scaffold_then_source_build_produces_artifacts() {
let dir = tempdir().unwrap();
scaffold_in(
dir.path(),
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
)
.unwrap();
let output = crate::build::run_source_build(
&dir.path().join("mytool"),
&crate::build::SourceBuildOptions::default(),
None,
)
.unwrap();
assert!(output.dsc.exists(), "{:?} missing", output.dsc);
assert!(output.buildinfo.exists(), "{:?} missing", output.buildinfo);
assert!(output.changes.exists(), "{:?} missing", output.changes);
// 3.0 (quilt): the orig tarball plus the debian diff tarball that
// dpkg-source generates for the debian/ directory.
assert_eq!(output.tarballs.len(), 2, "{:?}", output.tarballs);
assert!(output.tarballs[0].exists());
assert!(output.tarballs[1].exists());
// UNRELEASED: nothing is signed.
assert!(!output.signed);
}
}
+982
View File
@@ -0,0 +1,982 @@
//! Answers, validators and flag/default resolution for `pkh new`.
//!
//! [`NewCli`] carries the raw command-line answers (every flag optional),
//! [`resolve`] merges them with the built-in defaults and the project
//! detection (see [`crate::new::detect`]) into a fully-specified
//! [`NewOptions`], erroring with the list of every missing required answer
//! when running without an interactive wizard.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use regex::Regex;
use crate::debian::DebianVersion;
use crate::debian::deps::{Deps, ParseOpts};
use crate::distro_info;
use crate::new::detect::{self, Detection};
/// Build systems / project kinds `pkh new` knows about.
///
/// The identifiers are stable CLI surface: `--lang` accepts every variant,
/// 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`)
Rust,
/// Python project (`pyproject.toml` / `setup.py` / `setup.cfg`)
Python,
/// C/C++ with Meson (`meson.build`)
Meson,
/// C/C++ with CMake (`CMakeLists.txt`)
Cmake,
/// C/C++ with Autotools (`configure.ac`)
Autotools,
/// Go module (`go.mod`)
Go,
/// Shell script / single interpreted file
Shell,
/// Generic Makefile-based project
Makefile,
/// Metapackage / empty base (no build system)
Empty,
}
impl TemplateId {
/// Every template id, in the order offered by the wizard language menu.
pub fn all() -> [TemplateId; 9] {
[
TemplateId::Rust,
TemplateId::Python,
TemplateId::Meson,
TemplateId::Cmake,
TemplateId::Autotools,
TemplateId::Go,
TemplateId::Shell,
TemplateId::Makefile,
TemplateId::Empty,
]
}
/// Canonical CLI identifier of this template.
pub fn as_str(&self) -> &'static str {
match self {
TemplateId::Rust => "rust",
TemplateId::Python => "python",
TemplateId::Meson => "meson",
TemplateId::Cmake => "cmake",
TemplateId::Autotools => "autotools",
TemplateId::Go => "go",
TemplateId::Shell => "shell",
TemplateId::Makefile => "makefile",
TemplateId::Empty => "empty",
}
}
/// Parse a CLI identifier, accepting exactly the canonical spellings.
pub fn parse(s: &str) -> Result<TemplateId, String> {
TemplateId::all()
.into_iter()
.find(|id| id.as_str() == s)
.ok_or_else(|| {
format!(
"Unknown language/template '{}'. Supported values are: {}.",
s,
TemplateId::all()
.iter()
.map(|id| id.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})
}
/// 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)
}
}
impl std::fmt::Display for TemplateId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Where the upstream sources come from, and where the package tree lives.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceDir {
/// Generate a fresh project skeleton in `./<name>/`.
Skeleton,
/// Package the sources already sitting in the current directory.
Here,
/// Package the sources of another directory.
Path(PathBuf),
}
/// Upstream license of the package: a curated SPDX list plus a free-text
/// fallback for anything else (including "unknown" until the user picks one).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum License {
/// MIT
Mit,
/// Apache-2.0
Apache2,
/// GPL-2.0+
Gpl2Plus,
/// GPL-3.0+
Gpl3Plus,
/// LGPL-2.1+
Lgpl21Plus,
/// LGPL-3.0+
Lgpl3Plus,
/// BSD-2-Clause
Bsd2Clause,
/// BSD-3-Clause
Bsd3Clause,
/// ISC
Isc,
/// Any other license, spelled verbatim (also the fallback for
/// unrecognized `--license` values).
Custom(String),
}
impl License {
/// Map a license string to a [`License`]: curated SPDX identifiers are
/// matched case-insensitively, anything else becomes
/// [`License::Custom`] verbatim.
pub fn parse(s: &str) -> License {
match s.to_ascii_lowercase().as_str() {
"mit" => License::Mit,
"apache-2.0" => License::Apache2,
"gpl-2.0+" => License::Gpl2Plus,
"gpl-3.0+" => License::Gpl3Plus,
"lgpl-2.1+" => License::Lgpl21Plus,
"lgpl-3.0+" => License::Lgpl3Plus,
"bsd-2-clause" => License::Bsd2Clause,
"bsd-3-clause" => License::Bsd3Clause,
"isc" => License::Isc,
other => License::Custom(other.to_string()),
}
}
/// SPDX identifier used in `debian/copyright`.
pub fn spdx(&self) -> &str {
match self {
License::Mit => "MIT",
License::Apache2 => "Apache-2.0",
License::Gpl2Plus => "GPL-2.0+",
License::Gpl3Plus => "GPL-3.0+",
License::Lgpl21Plus => "LGPL-2.1+",
License::Lgpl3Plus => "LGPL-3.0+",
License::Bsd2Clause => "BSD-2-Clause",
License::Bsd3Clause => "BSD-3-Clause",
License::Isc => "ISC",
License::Custom(s) => s,
}
}
/// SPDX license data page URL (without the trailing `+` of the
/// "or later" spellings), for the copyright reference paragraph.
pub fn spdx_url(&self) -> String {
format!(
"https://spdx.org/licenses/{}.html",
self.spdx().trim_end_matches('+')
)
}
}
/// Fully-specified options of a `pkh new` run: the CLI answers after
/// defaults resolution and validation.
#[derive(Debug, Clone)]
pub struct NewOptions {
/// Source package name (validated).
pub name: String,
/// Template (language / build system) to scaffold.
pub template: TemplateId,
/// Where the upstream sources come from, and where the tree lives.
pub source_dir: SourceDir,
/// Upstream version (validated with [`DebianVersion`]).
pub upstream_version: String,
/// Debian revision, default 1.
pub revision: u32,
/// One-line description (the control `Description` synopsis).
pub summary: String,
/// Long description; defaults to the summary.
pub long_description: String,
/// Upstream homepage, when known.
pub homepage: Option<String>,
/// Upstream license.
pub license: License,
/// Installed command name, default = name (ignored by the empty
/// template).
pub command: String,
/// Maintainer (name, email).
pub maintainer: (String, String),
/// Target distribution (e.g. `ubuntu`, `debian`).
pub dist: String,
/// Target series: drives the printed next steps (and, with
/// [`NewOptions::release`], the changelog distribution).
pub series: String,
/// Write `series` into the changelog instead of UNRELEASED.
pub release: bool,
/// Runtime Depends clauses of the metapackage flavor (canonically
/// rendered; empty for every other flavor).
pub depends: Vec<String>,
/// Use the `3.0 (native)` source format (no orig tarball).
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 {
/// Directory the package tree lives in: `./<name>/` for the skeleton
/// mode, the given directory otherwise.
pub fn target_dir(&self, cwd: &Path) -> PathBuf {
match &self.source_dir {
SourceDir::Skeleton => cwd.join(&self.name),
SourceDir::Here => cwd.to_path_buf(),
SourceDir::Path(p) => p.clone(),
}
}
/// Full Debian version of the initial entry: `<upstream>-<revision>`.
pub fn full_version(&self) -> String {
format!("{}-{}", self.upstream_version, self.revision)
}
/// Upstream version as used in artifact file names (dpkg drops the
/// epoch from file names).
pub fn upstream_version_no_epoch(&self) -> String {
DebianVersion::parse(&self.full_version())
.map(|v| v.upstream)
.unwrap_or_else(|_| self.upstream_version.clone())
}
}
/// Raw command-line answers of `pkh new`, before defaults resolution.
///
/// Every field is optional so the interactive wizard (follow-up work) can
/// fill the same structure from its questions.
#[derive(Debug, Clone, Default)]
pub struct NewCli {
/// Positional `<name>`.
pub name: Option<String>,
/// `--lang` value (parsed by [`resolve`]).
pub lang: Option<String>,
/// `--source <path>`.
pub source: Option<PathBuf>,
/// `--upstream-version <v>`.
pub upstream_version: Option<String>,
/// `--revision <n>`.
pub revision: Option<u32>,
/// `--description <one-liner>`.
pub description: Option<String>,
/// `--homepage <url>`.
pub homepage: Option<String>,
/// `--license <SPDX>`.
pub license: Option<String>,
/// `--command <cmd>`.
pub command: Option<String>,
/// `--maintainer "Name <email>"`.
pub maintainer: Option<String>,
/// `--depends` values; each entry may itself be a comma-separated list.
pub depends: Vec<String>,
/// `--dist <dist>`.
pub dist: Option<String>,
/// `--series <s>`.
pub series: Option<String>,
/// `--release`.
pub release: bool,
/// `--native`.
pub native: bool,
/// True unless `--no-git`.
pub git: bool,
/// `--defaults`.
pub defaults: bool,
}
/// Validate a Debian source package name: `^[a-z0-9][a-z0-9+.\-]+$` with a
/// minimum length of 2 (dpkg rules).
pub fn validate_source_name(name: &str) -> Result<(), String> {
static NAME_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let regex = NAME_REGEX.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9+.\-]+$").unwrap());
if name.len() < 2 {
return Err(format!(
"'{name}' is too short: package names need at least 2 characters"
));
}
if !regex.is_match(name) {
return Err(format!(
"'{name}' is not a valid package name: names must be lowercase, \
start with a letter or digit and may only contain [a-z0-9+.-]"
));
}
Ok(())
}
/// Try to turn an arbitrary string (e.g. a directory name like `My Tool`)
/// into a valid package name: lowercased, whitespace/underscores turned into
/// `-`, leading/trailing `-` trimmed and repeated `-` collapsed. `None` when
/// nothing valid remains.
pub fn sanitize_name(input: &str) -> Option<String> {
let mut out = String::with_capacity(input.len());
let mut last_dash = false;
for c in input.chars() {
let c = c.to_ascii_lowercase();
if c.is_ascii_lowercase() || c.is_ascii_digit() {
out.push(c);
last_dash = false;
} else if c == '.' || c == '+' {
// Dpkg-valid characters kept verbatim.
out.push(c);
last_dash = false;
} else if c.is_whitespace() || c == '_' || c == '-' {
// Collapse whitespace/underscore runs into a single dash.
if !out.is_empty() && !last_dash {
out.push('-');
last_dash = true;
}
}
// Characters outside the dpkg name charset (everything but
// [a-z0-9+.-]) are dropped: a directory name is not a controlled
// input, so stay conservative there.
}
while out.ends_with('-') {
out.pop();
}
while out.starts_with('-') {
out.remove(0);
}
// A leading digit would be valid for dpkg but a name made only of
// digits/dots is rejected by the validator below anyway.
validate_source_name(&out).ok()?;
Some(out)
}
/// Validate an upstream version: it must start with a digit (dpkg
/// recommendation, enforced here) and survive [`DebianVersion::parse`] once
/// composed with the Debian revision. It must not contain `-` (the revision
/// separator).
pub fn validate_upstream_version(upstream: &str, revision: u32) -> Result<(), String> {
if !upstream.starts_with(|c: char| c.is_ascii_digit()) {
return Err("upstream versions should start with a digit".to_string());
}
if upstream.contains('-') {
return Err(
"upstream versions must not contain '-': the Debian revision is \
appended automatically"
.to_string(),
);
}
let composed = format!("{}-{}", upstream, revision);
DebianVersion::parse(&composed)
.map(|_| ())
.map_err(|e| format!("'{composed}' is not a valid Debian version: {e}"))
}
/// Parse a `Name <email>` maintainer string: the name must be non-empty and
/// the email must contain exactly one `@` with no whitespace inside the
/// angle brackets.
pub fn parse_maintainer(s: &str) -> Result<(String, String), String> {
static MAINTAINER_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let regex = MAINTAINER_REGEX.get_or_init(|| Regex::new(r"^(.+?)\s*<([^<>]*)>\s*$").unwrap());
let caps = regex.captures(s.trim()).ok_or_else(|| {
format!("'{s}' is not a valid maintainer: expected the form 'Name <email>'")
})?;
let name = caps[1].trim().to_string();
let email = caps[2].trim().to_string();
if name.is_empty() {
return Err(format!("'{s}': the maintainer name must not be empty"));
}
if !email.contains('@') || email.matches('@').count() > 1 {
return Err(format!(
"'{s}': the maintainer email must contain exactly one '@'"
));
}
if email.chars().any(char::is_whitespace) {
return Err(format!(
"'{s}': the maintainer email must not contain whitespace"
));
}
Ok((name, email))
}
/// Validate a homepage URL: it must carry an `http(s)://` prefix.
pub fn validate_homepage(url: &str) -> Result<(), String> {
if url.starts_with("http://") || url.starts_with("https://") {
Ok(())
} else {
Err(format!(
"'{url}' is not a valid homepage: expected an http:// or https:// URL"
))
}
}
/// Validate a comma-separated runtime Depends list (the metapackage flavor)
/// by parsing it with [`Deps::parse`] and re-rendering every clause
/// canonically.
pub fn validate_depends(list: &str) -> Result<Vec<String>, String> {
let opts = ParseOpts {
host_arch: crate::get_current_arch(),
build_arch: crate::get_current_arch(),
build_profiles: Vec::new(),
reduce_restrictions: false,
union: false,
build_dep: false,
};
let deps =
Deps::parse(list, &opts).map_err(|e| format!("Invalid Depends list '{list}': {e}"))?;
Ok(deps
.clauses()
.map(|clause| {
clause
.iter()
.map(|rel| rel.output())
.collect::<Vec<_>>()
.join(" | ")
})
.collect())
}
/// Resolve the CLI answers into fully-specified [`NewOptions`]: explicit
/// flags win, then detection (when `--lang` is not given), then defaults.
///
/// Missing required answers (name, template, description) are all reported
/// in a single error so non-interactive callers can fix everything at once.
pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
let mut missing: Vec<String> = Vec::new();
// Where do the sources come from?
let source_dir = match &cli.source {
Some(p) => SourceDir::Path(p.clone()),
None if cli.name.is_some() => SourceDir::Skeleton,
None => SourceDir::Here,
};
// Name: positional argument, else (with --defaults) the sanitized
// basename of the current directory.
let name = match &cli.name {
Some(n) => n.clone(),
None if cli.defaults => {
let cwd = std::env::current_dir()
.map_err(|e| format!("Could not determine the current directory: {e}"))?;
let base = cwd
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| "Could not derive a package name from the current directory; pass a name argument".to_string())?;
sanitize_name(base).ok_or_else(|| {
format!(
"Cannot derive a valid package name from the directory name \
'{base}'; pass a name argument"
)
})?
}
None => {
missing.push(
"package name (pass it as a positional argument, or use \
--defaults to derive it from the current directory name)"
.to_string(),
);
String::new()
}
};
// Template: --lang wins, else project detection in the source directory
// (nothing to detect for a fresh skeleton), else --defaults picks the
// empty template.
let template = match &cli.lang {
Some(lang) => Some(TemplateId::parse(lang)?),
None => match &source_dir {
SourceDir::Skeleton => {
if cli.defaults {
log::info!("No language given, --defaults picks the 'empty' template");
Some(TemplateId::Empty)
} else {
missing.push(format!(
"--lang <{}|...> (no language given and there is nothing \
to detect for a new project skeleton)",
TemplateId::all()
.iter()
.map(|id| id.as_str())
.collect::<Vec<_>>()
.join("|")
));
None
}
}
other => {
let dir = match other {
SourceDir::Here => std::env::current_dir()
.map_err(|e| format!("Could not determine the current directory: {e}"))?,
SourceDir::Path(p) => p.clone(),
SourceDir::Skeleton => unreachable!("handled above"),
};
match detect::detect(&dir) {
Detection::Single(id) => {
log::info!("Detected: {} project in {}", id, dir.display());
Some(id)
}
Detection::Ambiguous(candidates) => {
return Err(format!(
"Ambiguous project detection in '{}': multiple build \
systems found ({}). Pass --lang explicitly.",
dir.display(),
candidates
.iter()
.map(|id| id.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
Detection::Empty => {
if cli.defaults {
log::info!(
"No recognized project in {}, --defaults picks \
the 'empty' template",
dir.display()
);
Some(TemplateId::Empty)
} else {
missing.push(
"--lang <id> (could not detect a build system; \
run inside a project directory or pick one)"
.to_string(),
);
None
}
}
}
}
},
};
// One-line description: required without the wizard. The long
// description defaults to the summary.
let long_description = cli.description.clone().unwrap_or_default();
let summary = match &cli.description {
Some(d) => d.clone(),
None => {
missing.push("--description <one-liner>".to_string());
String::new()
}
};
if !missing.is_empty() {
return Err(format!(
"Missing required answers (use --defaults to take every default, \
or re-run pkh new on an interactive terminal to answer the \
wizard):\n - {}",
missing.join("\n - ")
));
}
// Everything below has a default and is validated as it is resolved.
validate_source_name(&name)?;
let upstream_version = cli.upstream_version.unwrap_or_else(|| "0.1.0".to_string());
let revision = cli.revision.unwrap_or(1);
validate_upstream_version(&upstream_version, revision)
.map_err(|e| format!("Invalid upstream version: {e}"))?;
let homepage = match &cli.homepage {
Some(h) => {
validate_homepage(h)?;
Some(h.clone())
}
None => None,
};
let license = License::parse(cli.license.as_deref().unwrap_or("unknown"));
let command = cli.command.unwrap_or_else(|| name.clone());
let maintainer = match &cli.maintainer {
Some(m) => parse_maintainer(m)?,
None => crate::changelog::get_maintainer_info().map_err(|e| {
format!(
"Could not determine the maintainer ({e}). \
Pass --maintainer \"Name <email>\"."
)
})?,
};
let dist = cli
.dist
.unwrap_or_else(|| crate::build::env::current_vendor().to_lowercase());
let series = match &cli.series {
Some(s) => s.clone(),
None if cli.release => {
return Err(
"--release requires --series: there is no series to release to".to_string(),
);
}
None => distro_info::effective_series(distro_info::UNRELEASED, &dist)
.await
.map_err(|e| {
format!("Could not resolve the default target series for '{dist}': {e}")
})?,
};
// The metapackage Depends list: comma-joined, parsed and re-rendered
// canonically.
let joined = cli.depends.join(", ");
let depends = if joined.trim().is_empty() {
Vec::new()
} else {
validate_depends(&joined)?
};
Ok(NewOptions {
name,
template: template.unwrap_or(TemplateId::Empty),
source_dir,
upstream_version,
revision,
summary,
long_description,
homepage,
license,
command,
maintainer,
dist,
series,
release: cli.release,
depends,
native: cli.native,
git: cli.git,
autopkgtest: false,
pkg_config: false,
watch: None,
})
}
/// Set of names no template may produce twice (collision check).
pub(crate) fn check_file_collisions(paths: &[String]) -> Result<(), String> {
let mut seen: HashSet<&String> = HashSet::new();
for path in paths {
if !seen.insert(path) {
return Err(format!(
"internal error: file '{path}' was generated more than once"
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
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());
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]
fn source_name_validator() {
for valid in ["mytool", "my-tool", "my.tool", "my+tool", "a1", "pkh9x"] {
assert!(validate_source_name(valid).is_ok(), "{valid} must pass");
}
for invalid in [
"A",
"a", // too short
"UPPER",
"Mixed", // uppercase
"-lead", // bad first char
"sp ace",
"under_score",
"bang!",
"",
"é", // bad charset
] {
assert!(
validate_source_name(invalid).is_err(),
"{invalid} must fail"
);
}
}
#[test]
fn sanitize_name_derives_valid_names() {
assert_eq!(sanitize_name("My Tool"), Some("my-tool".to_string()));
assert_eq!(sanitize_name("My_Tool"), Some("my-tool".to_string()));
assert_eq!(
sanitize_name(" spaced out "),
Some("spaced-out".to_string())
);
assert_eq!(
sanitize_name("trailing---dashes--"),
Some("trailing-dashes".to_string())
);
assert_eq!(sanitize_name("v1.2_beta"), Some("v1.2-beta".to_string()));
// Nothing sane remains.
assert_eq!(sanitize_name("---"), None);
assert_eq!(sanitize_name("A"), None);
assert_eq!(sanitize_name(""), None);
}
#[test]
fn upstream_version_validator() {
assert!(validate_upstream_version("0.1.0", 1).is_ok());
assert!(validate_upstream_version("1.0~rc1", 1).is_ok());
assert!(validate_upstream_version("20260101", 1).is_ok());
// Not starting with a digit.
assert!(validate_upstream_version("v1.0", 1).is_err());
// Revision separator inside the upstream version.
assert!(validate_upstream_version("1.0-2", 1).is_err());
// Invalid characters survive composition.
assert!(validate_upstream_version("1.0_0", 1).is_err());
// Epochs are accepted and compose fine.
assert!(validate_upstream_version("1:2.0", 1).is_ok());
// The full version round-trips through DebianVersion.
let opts = NewOptions {
name: "t".into(),
template: TemplateId::Empty,
source_dir: SourceDir::Here,
upstream_version: "0.1.0".into(),
revision: 1,
summary: "s".into(),
long_description: "s".into(),
homepage: None,
license: License::Mit,
command: "t".into(),
maintainer: ("A".into(), "a@b.c".into()),
dist: "debian".into(),
series: "sid".into(),
release: false,
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!(
DebianVersion::parse(&opts.full_version()).unwrap().full(),
"0.1.0-1"
);
// Epochs never leak into file names.
let epochy = NewOptions {
upstream_version: "1:2.0".into(),
..opts.clone()
};
assert_eq!(epochy.upstream_version_no_epoch(), "2.0");
assert_eq!(
DebianVersion::parse(&epochy.full_version()).unwrap().epoch,
Some(1)
);
}
#[test]
fn maintainer_parser() {
assert_eq!(
parse_maintainer("Jane Doe <jane@example.com>").unwrap(),
("Jane Doe".to_string(), "jane@example.com".to_string())
);
assert_eq!(
parse_maintainer(" X <x@y.z> ").unwrap(),
("X".to_string(), "x@y.z".to_string())
);
// Bad forms.
assert!(parse_maintainer("Jane Doe").is_err()); // no email
assert!(parse_maintainer("<jane@example.com>").is_err()); // no name
assert!(parse_maintainer("Jane <jane example.com>").is_err()); // no @
assert!(parse_maintainer("Jane <a@b@c>").is_err()); // two @s
assert!(parse_maintainer("Jane <jane@ example.com>").is_err()); // space
assert!(parse_maintainer("Jane <>").is_err()); // empty email
}
#[test]
fn homepage_validator() {
assert!(validate_homepage("https://example.com").is_ok());
assert!(validate_homepage("http://example.com/x").is_ok());
assert!(validate_homepage("ftp://example.com").is_err());
assert!(validate_homepage("example.com").is_err());
assert!(validate_homepage("").is_err());
}
#[test]
fn depends_parse_and_canonical_rerender() {
let clauses = validate_depends("foo (>= 1.0), bar").unwrap();
assert_eq!(clauses, vec!["foo (>= 1.0)", "bar"]);
// Canonicalization: legacy relations and odd spacing normalize.
let clauses = validate_depends("a(> 1), b | c").unwrap();
assert_eq!(clauses, vec!["a (>= 1)", "b | c"]);
// Invalid clauses are rejected.
assert!(validate_depends("foo (>= ), bar").is_err());
assert!(validate_depends("a, ,@!").is_err());
// Empty list.
assert!(validate_depends("").unwrap().is_empty());
}
#[test]
fn license_parsing() {
assert_eq!(License::parse("MIT"), License::Mit);
assert_eq!(License::parse("mit"), License::Mit);
assert_eq!(License::parse("GPL-3.0+"), License::Gpl3Plus);
assert_eq!(License::parse("bsd-3-clause"), License::Bsd3Clause);
assert_eq!(License::parse("Zlib"), License::Custom("zlib".to_string()));
assert_eq!(License::Mit.spdx(), "MIT");
assert_eq!(License::Gpl2Plus.spdx(), "GPL-2.0+");
assert_eq!(
License::Gpl2Plus.spdx_url(),
"https://spdx.org/licenses/GPL-2.0.html"
);
}
#[test]
fn collision_detection() {
assert!(check_file_collisions(&["a".into(), "b".into()]).is_ok());
let err = check_file_collisions(&["a".into(), "b".into(), "a".into()]).unwrap_err();
assert!(err.contains('a'));
}
#[tokio::test]
async fn resolve_reports_every_missing_answer() {
// --source an empty directory: detection finds nothing, so the
// language joins the missing answers (and all of them are listed
// at once).
let dir = tempfile::tempdir().unwrap();
let cli = NewCli {
source: Some(dir.path().to_path_buf()),
..Default::default()
};
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("package name"), "{err}");
assert!(err.contains("--lang"), "{err}");
assert!(err.contains("--description"), "{err}");
}
#[tokio::test]
async fn resolve_defaults_fill_everything_but_description() {
// With --defaults the language defaults to 'empty' when detection
// finds nothing; the description stays required without a wizard.
let dir = tempfile::tempdir().unwrap();
let cli = NewCli {
name: Some("mytool".into()),
source: Some(dir.path().to_path_buf()),
defaults: true,
git: true,
..Default::default()
};
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("--description"), "{err}");
assert!(!err.contains("--lang"), "{err}");
}
#[tokio::test]
async fn resolve_merges_flags_defaults_and_detection() {
// Detection against an empty --source directory with --defaults
// picks the empty template.
let dir = tempfile::tempdir().unwrap();
let cli = NewCli {
name: Some("my-tool".into()),
source: Some(dir.path().to_path_buf()),
defaults: true,
description: Some("Does things".into()),
depends: vec!["hello (>= 1.0), hello-data".into()],
git: true,
..Default::default()
};
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.name, "my-tool");
assert_eq!(opts.template, TemplateId::Empty);
assert!(matches!(opts.source_dir, SourceDir::Path(_)));
assert_eq!(opts.upstream_version, "0.1.0");
assert_eq!(opts.revision, 1);
assert_eq!(opts.summary, "Does things");
assert_eq!(opts.long_description, "Does things");
assert_eq!(opts.homepage, None);
assert_eq!(opts.license, License::Custom("unknown".into()));
assert_eq!(opts.command, "my-tool");
assert_eq!(opts.depends, vec!["hello (>= 1.0)", "hello-data"]);
assert!(opts.git);
assert!(!opts.release);
// Series: the development series of the current vendor (lowercased,
// matching the distro-info keys).
let dist = crate::build::env::current_vendor().to_lowercase();
assert_eq!(opts.dist, dist);
assert_eq!(
opts.series,
crate::distro_info::effective_series(crate::distro_info::UNRELEASED, &dist)
.await
.unwrap()
);
}
#[tokio::test]
async fn resolve_detects_the_project_in_source_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("go.mod"), "module example.com/x\n").unwrap();
let cli = NewCli {
name: Some("xtool".into()),
source: Some(dir.path().to_path_buf()),
description: Some("An X".into()),
maintainer: Some("Jane <jane@example.com>".into()),
git: true,
..Default::default()
};
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.template, TemplateId::Go);
}
#[tokio::test]
async fn release_requires_series() {
let cli = NewCli {
name: Some("mytool".into()),
lang: Some("empty".into()),
description: Some("A tool".into()),
release: true,
maintainer: Some("Jane <jane@example.com>".into()),
git: true,
..Default::default()
};
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("--release requires --series"), "{err}");
}
}
+977
View File
@@ -0,0 +1,977 @@
//! 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",
];
/// Labels of the interactive `select` questions. `prompt::select` renders
/// `> <label><answer>` verbatim — unlike [`prompt::text`], it appends no
/// formatting of its own — so each label carries its own separator:
/// field-style prompts end with `": "`, question-style ones with `"? "`.
const LANGUAGE_LABEL: &str = "Which language/build system is your program using? ";
const SOURCE_LABEL: &str = "Where is the source code? ";
const LICENSE_LABEL: &str = "License: ";
const DIST_LABEL: &str = "Target distribution: ";
const SERIES_LABEL: &str = "Target series: ";
/// All select labels, so the separator test can check them in one place.
#[cfg(test)]
const SELECT_LABELS: [&str; 5] = [
LANGUAGE_LABEL,
SOURCE_LABEL,
LICENSE_LABEL,
DIST_LABEL,
SERIES_LABEL,
];
/// 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(SOURCE_LABEL, &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_LABEL, &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(DIST_LABEL, &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(SERIES_LABEL, &series, &series[0])?;
cli.series = Some(answer);
}
_ => {
log::warn!(
"Could not fetch the series list for '{dist}'; \
defaulting to its development series"
);
}
}
}
// 13. Metapackage Depends (empty template only).
if template == TemplateId::Empty && cli.depends.is_empty() {
let answer = ask_text(
"Depends (metapackage, comma-separated, blank for an empty base)",
"",
|answer: &str| options::validate_depends(answer).map(|_| ()),
)?;
if !answer.trim().is_empty() {
cli.depends = vec![answer];
}
}
// 14. Git init.
cli.git = prompt::confirm("Initialize a git repository?", cli.git)?;
// Resolve through the same pipeline as the non-interactive path: one
// source of truth for defaults and validation.
let mut opts = options::resolve(cli).await?;
// The meson/cmake opt-in question of the spec's template table: does the
// build resolve libraries through pkg-config? The project files prefill
// the default (dependency() / pkg_check_modules calls found).
if matches!(template, TemplateId::Meson | TemplateId::Cmake)
&& prompt::confirm(
"Does the build resolve libraries through pkg-config (add it to Build-Depends)?",
pkg_config_hint(&detect_dir, template),
)?
{
opts.pkg_config = true;
}
// Wizard-only extras (default off).
if template != TemplateId::Empty
&& prompt::confirm(
"Add an autopkgtest smoke test (debian/tests/control)?",
false,
)?
{
opts.autopkgtest = true;
}
if let Some(watch) = watch_template(opts.homepage.as_deref())
&& prompt::confirm("Add a debian/watch release watcher?", false)?
{
opts.watch = Some(watch);
}
// Summary screen + final confirmation: Ctrl+C or 'n' abort with
// nothing written (generation is all-or-nothing later anyway).
println!("{}", summary_text(&opts));
if !prompt::confirm("Generate?", true)? {
return Err("Aborted: nothing was written to disk.".into());
}
Ok(opts)
}
/// The post-scaffold verification offers (spec "Verification" steps 2–3),
/// interactive only and skipped with `--no-verify`: the source build
/// (`pkh build`, offered yes) and the binary build (`pkh deb`, offered no —
/// it needs network + build deps). A failed verification build never undoes
/// the scaffold: the error is printed together with the manual next steps.
pub async fn offer_verification(opts: &NewOptions, multi: &MultiProgress, no_verify: bool) {
if no_verify || !is_interactive() {
return;
}
let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default());
let display = crate::ui::display_path(&tree);
let display = if display.is_empty() {
".".to_string()
} else {
display
};
let verify_source = match prompt::confirm("Verify with `pkh build` now?", true) {
Ok(answer) => answer,
Err(_) => return,
};
if !verify_source {
return;
}
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
if let Err(e) = crate::build::build_source_package(Some(&tree), ui) {
log::error!("Verification source build failed: {e}");
log::info!(
"The scaffolded tree is intact. Inspect it, then retry with \
`cd {display} && pkh build`."
);
log::info!(
"Hint: failures here usually come from a missing build dependency \
or build file, not from the scaffold itself; check debian/control \
and the template's build file."
);
return;
}
let verify_deb = match prompt::confirm(
"Verify with `pkh deb` now? (needs network + build deps)",
false,
) {
Ok(answer) => answer,
Err(_) => return,
};
if !verify_deb {
return;
}
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
if let Err(e) = crate::deb::build_binary_package(
None,
Some(&opts.series),
None,
Some(&tree),
false,
None,
None,
None,
None,
ui,
None,
)
.await
{
log::error!("Verification binary build failed: {e}");
log::info!(
"The scaffolded tree is intact. Once the build dependencies are \
available, retry with `cd {display} && pkh deb`."
);
log::info!(
"Hint: when a build dependency is missing from the {} archive, \
`pkh deb --inject <package>` makes it available in the build \
environment (e.g. a PEP 517 backend like python3-poetry-core).",
opts.series
);
}
}
/// The preselected default of the pkg-config opt-in question: whether the
/// project's build file hints at pkg-config usage (`dependency(` in
/// meson.build, `pkg_check_modules` / `find_package(PkgConfig` in
/// CMakeLists.txt).
fn pkg_config_hint(dir: &std::path::Path, template: TemplateId) -> bool {
let (file, needles): (&str, &[&str]) = match template {
TemplateId::Meson => ("meson.build", &["dependency("]),
TemplateId::Cmake => (
"CMakeLists.txt",
&[
"pkg_check_modules",
"find_package(pkgconfig",
"find_package(pkg_config",
],
),
_ => return false,
};
std::fs::read_to_string(dir.join(file))
.map(|content| {
let lower = content.to_ascii_lowercase();
needles.iter().any(|needle| lower.contains(needle))
})
.unwrap_or(false)
}
/// The default package name: the detected project's name, else the
/// sanitized basename of the current directory.
fn default_package_name(cwd: &std::path::Path, probe: Option<&ProbeResult>) -> String {
probe
.and_then(|p| p.name.as_deref())
.and_then(options::sanitize_name)
.or_else(|| {
cwd.file_name()
.and_then(std::ffi::OsStr::to_str)
.and_then(options::sanitize_name)
})
.unwrap_or_default()
}
/// The language menu: detected candidates first (in their detection order),
/// then every other template in registry order.
fn language_menu(candidates: &[TemplateId]) -> Vec<String> {
candidates
.iter()
.copied()
.chain(
TemplateId::all()
.into_iter()
.filter(|id| !candidates.contains(id)),
)
.map(|id| id.display_name().to_string())
.collect()
}
/// The license menu: the curated SPDX list plus the free-text entry.
fn license_menu() -> Vec<String> {
KNOWN_LICENSES
.iter()
.copied()
.map(str::to_string)
.chain(std::iter::once(LICENSE_OTHER.to_string()))
.collect()
}
/// The defaults of the license question for a probed SPDX identifier: the
/// matching curated entry (case-insensitive) is preselected; anything else
/// preselects the free-text entry prefilled with the probe. Without a probe
/// the curated list defaults to MIT.
fn license_question_default(probe_license: Option<&str>) -> (String, String) {
match probe_license {
Some(license) => match KNOWN_LICENSES
.iter()
.find(|k| k.eq_ignore_ascii_case(license))
{
Some(known) => ((*known).to_string(), String::new()),
None => (LICENSE_OTHER.to_string(), license.to_string()),
},
None => ("MIT".to_string(), String::new()),
}
}
/// Ask the language question until a known template label (or CLI
/// identifier) is answered — the selector allows typing arbitrary text.
fn select_template(options: &[String], default: &str) -> Result<TemplateId, Box<dyn Error>> {
loop {
let answer = prompt::select(LANGUAGE_LABEL, 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());
}
#[test]
fn select_labels_carry_their_own_separator() {
// prompt::select renders `> <label><answer>` verbatim; a label
// without a trailing separator glues the answer to the prompt
// (regression: the wizard once rendered "> LicenseMIT").
for label in SELECT_LABELS {
assert!(
label.ends_with(": ") || label.ends_with("? "),
"select label {label:?} lacks a trailing separator"
);
}
}
}
+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());
}
}
+88
View File
@@ -0,0 +1,88 @@
//! The `empty` template: a metapackage or an empty base package with no
//! build system at all.
//!
//! One template with two flavors: a non-empty `Depends` list selects the
//! **metapackage** flavor (the canonical `Architecture: all`, nothing
//! compiled, the Depends list *is* the payload shape), while an empty list
//! selects the **empty base** — pure `dh $@` plumbing as a starting point
//! for hand-written rules.
use super::{OutputFile, Template};
use crate::new::options::NewOptions;
/// Metapackage / empty base (no build system).
pub struct Empty;
impl Template for Empty {
fn id(&self) -> crate::new::options::TemplateId {
crate::new::options::TemplateId::Empty
}
/// No upstream files; just a stub `README` marking the tree as
/// intentionally empty.
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
vec![OutputFile::new(
"README",
format!(
"{} - empty base tree scaffolded by `pkh new`; there is \
intentionally no upstream build system here.\n",
opts.name
),
)]
}
/// No extra debian/ files: the metapackage `Depends` list is carried by
/// [`NewOptions::depends`] into the common `debian/control` rendering.
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new::options::{License, SourceDir, TemplateId};
fn opts(depends: Vec<String>) -> NewOptions {
NewOptions {
name: "metapkg".into(),
template: TemplateId::Empty,
source_dir: SourceDir::Skeleton,
upstream_version: "0.1.0".into(),
revision: 1,
summary: "A metapackage".into(),
long_description: "A metapackage".into(),
homepage: None,
license: License::Custom("unknown".into()),
command: "ignored".into(),
maintainer: ("Jane".into(), "jane@example.com".into()),
dist: "debian".into(),
series: "sid".into(),
release: false,
depends,
native: false,
git: true,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
#[test]
fn empty_template_shape() {
let template = super::super::get(TemplateId::Empty).unwrap();
// 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(&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);
assert_eq!(skeleton[0].path, "README");
assert!(!skeleton[0].executable);
assert!(skeleton[0].contents.contains("metapkg"));
}
}
+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()]
);
}
}
+388
View File
@@ -0,0 +1,388 @@
//! Per-ecosystem template registry for `pkh new`.
//!
//! Every template implements [`Template`]: it renders the upstream-side
//! skeleton files, the extra `debian/` files beyond the common set, probes an
//! existing project for metadata used to pre-fill the wizard answers, and
//! describes its Build-Depends / architecture / `debian/rules` shape.
//! Rendering is plain `format!` composition — no template engine, matching
//! the codebase style.
pub mod autotools;
pub mod cmake;
pub mod empty;
pub mod go;
pub mod makefile;
pub mod meson;
pub mod python;
pub mod rust;
pub mod shell;
use std::path::{Path, PathBuf};
use super::options::{NewOptions, SourceDir, TemplateId};
/// One generated file, rendered in memory before anything touches the disk.
#[derive(Debug, Clone)]
pub struct OutputFile {
/// Path relative to the package tree root (e.g. `debian/control`).
pub path: String,
/// Full file contents.
pub contents: String,
/// Whether the file carries the executable bit (mode 0755).
pub executable: bool,
}
impl OutputFile {
/// A regular (non-executable) file.
pub fn new(path: impl Into<String>, contents: impl Into<String>) -> OutputFile {
OutputFile {
path: path.into(),
contents: contents.into(),
executable: false,
}
}
/// An executable file (mode 0755).
pub fn executable(path: impl Into<String>, contents: impl Into<String>) -> OutputFile {
OutputFile {
executable: true,
..OutputFile::new(path, contents)
}
}
}
/// Metadata extracted from an existing project by [`Template::probe`], used
/// by the interactive wizard to pre-fill its answers (explicit flags always
/// win). Every field is optional; probe failures are silent and the generic
/// defaults apply.
#[derive(Debug, Clone, Default)]
pub struct ProbeResult {
/// Project name (e.g. the `name` key of `Cargo.toml`).
pub name: Option<String>,
/// Project version.
pub version: Option<String>,
/// Project description.
pub description: Option<String>,
/// Project homepage.
pub homepage: Option<String>,
/// Project license (SPDX identifier).
pub license: Option<String>,
/// Installed command / binary name (e.g. the first `[[bin]]` target or
/// console script).
pub command: Option<String>,
}
/// A package template: one supported ecosystem / build system.
///
/// `Sync` is required so templates can live in the static registry.
pub trait Template: Sync {
/// Identifier of this template.
fn id(&self) -> TemplateId;
/// Upstream-side files for the skeleton mode (e.g. `Cargo.toml`,
/// `src/main.rs`). Only called when packaging a fresh skeleton.
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile>;
/// Extra `debian/` files beyond the common set rendered by
/// [`super::debian`] (e.g. `debian/install`).
fn debian(&self, opts: &NewOptions) -> Vec<OutputFile>;
/// Build-Depends beyond `debhelper-compat (= 13)`.
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
Vec::new()
}
/// Architecture of the binary package (`all` or `any`).
fn architecture(&self, _opts: &NewOptions) -> &'static str {
"all"
}
/// The `dh` invocation (without leading tab) used by the `%:` target of
/// `debian/rules`. Templates needing more than the plain `dh $@` spell
/// their buildsystem/sequencer options here so the generated rules stay
/// valid make.
fn rules_dh_line(&self) -> String {
"dh $@".to_string()
}
/// Lines appended to `debian/rules` after the default `dh $@` stanza
/// (e.g. `override_dh_*` targets). Must use tabs for recipe lines.
fn rules_extra(&self, _opts: &NewOptions) -> String {
String::new()
}
/// Extra `debian/control` source-stanza fields beyond the common set
/// (e.g. `XS-Go-Import-Path`).
fn source_fields(&self, _opts: &NewOptions) -> Vec<(String, String)> {
Vec::new()
}
/// 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.
static TEMPLATES: &[&dyn Template] = &[
&SHELL, &EMPTY, &MAKEFILE, &PYTHON, &MESON, &CMAKE, &AUTOTOOLS, &GO, &RUST,
];
/// Look up the template implementation for `id`; `None` only if a
/// [`TemplateId`] ever grows without a registered template (callers turn
/// this into a friendly error instead of panicking).
pub fn get(id: TemplateId) -> Option<&'static dyn Template> {
match id {
TemplateId::Shell => Some(&SHELL),
TemplateId::Empty => Some(&EMPTY),
TemplateId::Makefile => Some(&MAKEFILE),
TemplateId::Python => Some(&PYTHON),
TemplateId::Meson => Some(&MESON),
TemplateId::Cmake => Some(&CMAKE),
TemplateId::Autotools => Some(&AUTOTOOLS),
TemplateId::Go => Some(&GO),
TemplateId::Rust => Some(&RUST),
}
}
/// Every implemented template.
pub fn all() -> &'static [&'static dyn Template] {
TEMPLATES
}
/// Locate `name` on `$PATH` (a tiny `which`): `None` when `PATH` is unset or
/// nothing executable-looking matches.
pub(crate) fn find_on_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(name))
.find(|candidate| candidate.is_file())
}
/// The directory whose sources are being packaged, when there is one:
/// `None` for the skeleton mode (the skeleton files are rendered in memory
/// and do not exist on disk yet).
pub(crate) fn source_dir_of(opts: &NewOptions) -> Option<PathBuf> {
match &opts.source_dir {
SourceDir::Skeleton => None,
SourceDir::Here => std::env::current_dir().ok(),
SourceDir::Path(path) => Some(path.clone()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_covers_every_template() {
for id in TemplateId::all() {
assert!(get(id).is_some(), "{id} must be registered");
assert_eq!(get(id).unwrap().id(), id);
}
assert_eq!(all().len(), TemplateId::all().len());
}
#[test]
fn probe_defaults_to_none() {
assert!(
get(TemplateId::Shell)
.unwrap()
.probe(Path::new("/"))
.is_none()
);
}
/// 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]"));
}
}
}
+136
View File
@@ -0,0 +1,136 @@
//! The `shell` template: a single interpreted script installed to
//! `/usr/bin` with plain `dh $@` plumbing.
use std::path::Path;
use super::{OutputFile, ProbeResult, Template};
use crate::new::options::{self, NewOptions, SourceDir};
/// Shell script / single interpreted file.
pub struct Shell;
impl Template for Shell {
fn id(&self) -> crate::new::options::TemplateId {
crate::new::options::TemplateId::Shell
}
/// A minimal executable script named after the command, with a `#!/bin/sh`
/// shebang and an `echo` placeholder.
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
vec![OutputFile::executable(
format!("{}.sh", opts.command),
format!(
"#!/bin/sh\n# Placeholder for {}, generated by `pkh new`.\n\
echo \"Hello from {}!\"\n",
opts.name, opts.command
),
)]
}
/// `debian/install` mapping the script into `/usr/bin/<command>`
/// (debian/install renames when the destination carries a file name).
/// Only for the skeleton mode: when packaging an existing tree the
/// generated mapping would reference the non-existent skeleton script,
/// so the user writes their own install file instead.
fn debian(&self, opts: &NewOptions) -> Vec<OutputFile> {
if !matches!(opts.source_dir, SourceDir::Skeleton) {
return Vec::new();
}
vec![OutputFile::new(
"debian/install",
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 {
name: "mytool".into(),
template: TemplateId::Shell,
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 shell_template_shape() {
let o = opts();
let template = super::super::get(TemplateId::Shell).unwrap();
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);
assert_eq!(skeleton[0].path, "mytool.sh");
assert!(skeleton[0].executable);
assert!(skeleton[0].contents.starts_with("#!/bin/sh\n"));
let debian = template.debian(&o);
assert_eq!(debian.len(), 1);
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());
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Structural self-checks of a freshly scaffolded package tree (step 1 of
//! the spec's "try very hard" verification): every check is cheap, local
//! and re-parses the generated files with the same parsers the build
//! pipeline uses, so `pkh build` failures are caught at generation time
//! when they come from a pkh bug rather than from the tree.
use std::path::Path;
use crate::debian::{ControlInfo, DebianVersion};
/// Verify the structural sanity of the scaffolded tree at `tree`:
///
/// - `debian/rules` exists and carries the executable bit,
/// - `debian/control` parses as deb822 (source stanza + binary stanza),
/// - `debian/changelog` parses,
/// - `debian/source/format` is one of the known values,
/// - for quilt packages, the orig tarball exists next to the tree.
pub fn verify(tree: &Path) -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt;
// debian/rules: present + executable.
let rules_path = tree.join("debian/rules");
let rules_mode = std::fs::metadata(&rules_path)
.map_err(|e| format!("'{}' is missing: {e}", rules_path.display()))?
.permissions()
.mode();
if rules_mode & 0o111 == 0 {
return Err(format!(
"'{}' is not executable (pkh's build fixtures require the exec bit)",
rules_path.display()
)
.into());
}
// debian/control: re-parse with the real parser.
let control_path = tree.join("debian/control");
let control = ControlInfo::parse(&control_path)
.map_err(|e| format!("Generated '{}' does not parse: {e}", control_path.display()))?;
if control.binaries.is_empty() {
return Err(format!(
"Generated '{}' has no binary package stanza",
control_path.display()
)
.into());
}
// debian/changelog: re-parse with the real parser.
let changelog_path = tree.join("debian/changelog");
let (source, version, _distribution) =
crate::changelog::parse_changelog_header(&changelog_path).map_err(|e| {
format!(
"Generated '{}' does not parse: {e}",
changelog_path.display()
)
})?;
let parsed_version = DebianVersion::parse(&version)
.map_err(|e| format!("Generated changelog version '{version}' is invalid: {e}"))?;
// debian/source/format: one of the three known values.
let format_path = tree.join("debian/source/format");
let format = std::fs::read_to_string(&format_path)
.map_err(|e| format!("'{}' is missing: {e}", format_path.display()))?;
let format = format.trim();
if !super::debian::KNOWN_SOURCE_FORMATS.contains(&format) {
return Err(format!(
"'{}' carries the unknown source format '{format}'",
format_path.display()
)
.into());
}
// Quilt packages need their orig tarball next to the tree.
if format == super::debian::SOURCE_FORMAT_QUILT {
let uversion = parsed_version.upstream;
let tarball =
super::debian::orig_tarball_path(tree, &source, &uversion).ok_or_else(|| {
format!(
"cannot determine the parent directory of '{}'",
tree.display()
)
})?;
if !tarball.exists() {
return Err(format!(
"Quilt package without orig tarball: '{}' is missing. \
Re-run pkh new, or pass --native.",
tarball.display()
)
.into());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::new::options::{License, NewOptions, SourceDir, TemplateId};
use tempfile::tempdir;
fn opts() -> NewOptions {
NewOptions {
name: "mytool".into(),
template: TemplateId::Shell,
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: "debian".into(),
series: "sid".into(),
release: false,
depends: Vec::new(),
native: false,
git: false,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
/// Scaffold a complete tree with the given options (without git, so no
/// fixture below depends on the git binary).
fn scaffold_tree(dir: &Path, opts: &NewOptions) -> std::path::PathBuf {
let template = crate::new::templates::get(opts.template).unwrap();
let tree = dir.join("tree");
std::fs::create_dir_all(&tree).unwrap();
let mut files = crate::new::debian::files(opts, template);
files.extend(template.skeleton(opts));
files.extend(template.debian(opts));
crate::new::debian::write_files(&tree, &files).unwrap();
if !opts.native {
crate::new::debian::create_orig_tarball(&tree, &opts.name, &opts.upstream_version)
.unwrap();
}
tree
}
#[test]
fn verify_accepts_a_good_tree() {
let dir = tempdir().unwrap();
let tree = scaffold_tree(dir.path(), &opts());
verify(&tree).unwrap();
}
#[test]
fn verify_accepts_native_trees_without_tarball() {
let dir = tempdir().unwrap();
let tree = scaffold_tree(
dir.path(),
&NewOptions {
native: true,
..opts()
},
);
verify(&tree).unwrap();
}
#[test]
fn verify_names_the_broken_file() {
// Each case needs its own tempdir: scaffolding refuses to overwrite
// an existing orig tarball.
let o = opts();
// Missing rules.
let dir = tempdir().unwrap();
let tree = scaffold_tree(dir.path(), &o);
std::fs::remove_file(tree.join("debian/rules")).unwrap();
let err = verify(&tree).unwrap_err().to_string();
assert!(err.contains("debian/rules"), "{err}");
// Non-executable rules.
let dir = tempdir().unwrap();
let tree = scaffold_tree(dir.path(), &o);
let mut perms = std::fs::metadata(tree.join("debian/rules"))
.unwrap()
.permissions();
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o644);
std::fs::set_permissions(tree.join("debian/rules"), perms).unwrap();
let err = verify(&tree).unwrap_err().to_string();
assert!(err.contains("not executable"), "{err}");
// Broken control.
let dir = tempdir().unwrap();
let tree = scaffold_tree(dir.path(), &o);
std::fs::write(tree.join("debian/control"), "not: a\ncontrol\n").unwrap();
let err = verify(&tree).unwrap_err().to_string();
assert!(err.contains("debian/control"), "{err}");
// Broken changelog.
let dir = tempdir().unwrap();
let tree = scaffold_tree(dir.path(), &o);
std::fs::write(tree.join("debian/changelog"), "garbage\n").unwrap();
let err = verify(&tree).unwrap_err().to_string();
assert!(err.contains("debian/changelog"), "{err}");
// Unknown source format.
let dir = tempdir().unwrap();
let tree = scaffold_tree(dir.path(), &o);
std::fs::write(tree.join("debian/source/format"), "42.0 (quilt)\n").unwrap();
let err = verify(&tree).unwrap_err().to_string();
assert!(err.contains("debian/source/format"), "{err}");
// Missing orig tarball.
let dir = tempdir().unwrap();
let tree = scaffold_tree(dir.path(), &o);
std::fs::remove_file(dir.path().join("mytool_0.1.0.orig.tar.xz")).unwrap();
let err = verify(&tree).unwrap_err().to_string();
assert!(err.contains("orig tarball"), "{err}");
}
}
+21 -160
View File
@@ -5,14 +5,11 @@
pub mod deb; pub mod deb;
/// Line classifiers rewriting raw subprocess output for the live views /// Line classifiers rewriting raw subprocess output for the live views
pub mod logfmt; pub mod logfmt;
/// Interactive raw-mode prompts: free-text input, option selection and
/// yes/no confirmation
pub mod prompt;
use crossterm::{
cursor, event, execute,
style::{self, Color, Print, SetForegroundColor},
terminal,
};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::io::{self, Write};
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
@@ -75,7 +72,9 @@ pub fn create_progress_bar(
(pb, callback) (pb, callback)
} }
const SELECT_PROMPT: &str = "> Select series: "; /// Label of the interactive series selector, rendered by [`prompt::select`]
/// as `> <label><choice>`
const SELECT_SERIES_LABEL: &str = "Select series: ";
/// Interactive one-line series selector with arrow key navigation and direct typing. /// Interactive one-line series selector with arrow key navigation and direct typing.
/// ///
@@ -86,165 +85,27 @@ const SELECT_PROMPT: &str = "> Select series: ";
/// - Press Enter to confirm, Escape to use the default /// - Press Enter to confirm, Escape to use the default
/// ///
/// Returns the selected series name, or an error if cancelled (Ctrl+C). /// Returns the selected series name, or an error if cancelled (Ctrl+C).
/// Without a terminal (piped input) or with no options, falls back to the
/// default.
pub fn select_series( pub fn select_series(
options: &[String], options: &[String],
default: &str, default: &str,
) -> Result<String, Box<dyn std::error::Error>> { ) -> Result<String, Box<dyn std::error::Error>> {
// Nothing to choose, or no terminal to ask: announce the default and take it
let fallback = || {
println!("> {SELECT_SERIES_LABEL}{default} (default)");
Ok(default.to_string())
};
if options.is_empty() { if options.is_empty() {
println!("{}{} (default)", SELECT_PROMPT, default); return fallback();
return Ok(default.to_string());
} }
// Try to enter raw mode — if not possible (e.g. piped input), fall back to default match prompt::select(SELECT_SERIES_LABEL, options, default) {
if terminal::enable_raw_mode().is_err() { Ok(series) => Ok(series),
println!("{}{} (default)", SELECT_PROMPT, default); // prompt::select refuses to answer without a TTY; keep the
return Ok(default.to_string()); // historical fallback to the default
} Err(err) if err.is::<prompt::PromptError>() => fallback(),
Err(err) => Err(err),
let result = select_series_inner(options, default);
// Always restore the terminal
let _ = terminal::disable_raw_mode();
result
}
fn select_series_inner(
options: &[String],
default: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let default_idx = options.iter().position(|o| o == default).unwrap_or(0);
let mut selected_idx = default_idx;
let mut input = default.to_string();
// Whether we are in "navigation mode" (last action was arrow/tab selecting an option)
// vs "typing mode" (last action was typing a character)
let mut navigating = true;
let prompt_len = SELECT_PROMPT.len();
let mut stdout = io::stdout();
// Draw the prompt line, clearing any previous content
let draw = |stdout: &mut io::Stdout, input: &str| -> Result<(), Box<dyn std::error::Error>> {
execute!(
stdout,
cursor::MoveToColumn(0),
Print(SELECT_PROMPT),
SetForegroundColor(Color::Cyan),
Print(input),
style::ResetColor,
terminal::Clear(terminal::ClearType::UntilNewLine),
)?;
stdout.flush()?;
Ok(())
};
draw(&mut stdout, &input)?;
loop {
if event::poll(Duration::from_millis(200))?
&& let event::Event::Key(event::KeyEvent {
code, modifiers, ..
}) = event::read()?
{
match code {
event::KeyCode::Up => {
navigating = true;
if selected_idx > 0 {
selected_idx -= 1;
} else {
selected_idx = options.len() - 1;
}
input = options[selected_idx].clone();
draw(&mut stdout, &input)?;
}
event::KeyCode::Down => {
navigating = true;
if selected_idx < options.len() - 1 {
selected_idx += 1;
} else {
selected_idx = 0;
}
input = options[selected_idx].clone();
draw(&mut stdout, &input)?;
}
event::KeyCode::Enter => {
break;
}
event::KeyCode::Esc => {
input = default.to_string();
break;
}
event::KeyCode::Char(c) => {
if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' {
// Move to a new line before returning so the terminal isn't messed up
execute!(stdout, Print("\r\n"))?;
return Err("Cancelled".into());
}
navigating = false;
input.push(c);
// Auto-select if the input exactly matches an option
if let Some(idx) = options.iter().position(|o| o == &input) {
selected_idx = idx;
navigating = true;
}
draw(&mut stdout, &input)?;
}
event::KeyCode::Backspace => {
if !input.is_empty() {
input.pop();
navigating = false;
if let Some(idx) = options.iter().position(|o| o == &input) {
selected_idx = idx;
navigating = true;
}
// Need to redraw — move cursor to end of prompt + input
execute!(
stdout,
cursor::MoveToColumn(prompt_len as u16),
Print(&input),
terminal::Clear(terminal::ClearType::UntilNewLine),
)?;
stdout.flush()?;
} }
} }
event::KeyCode::Tab => {
// Cycle through options that start with the current input
let matches: Vec<usize> = options
.iter()
.enumerate()
.filter(|(_, o)| o.starts_with(&input))
.map(|(i, _)| i)
.collect();
if !matches.is_empty() {
let next = if navigating {
matches
.iter()
.find(|&&i| i > selected_idx)
.or_else(|| matches.first())
} else {
matches.first()
};
if let Some(&idx) = next {
selected_idx = idx;
input = options[idx].clone();
navigating = true;
}
draw(&mut stdout, &input)?;
}
}
_ => {}
}
}
}
// Move to a new line so subsequent output doesn't overwrite the prompt
execute!(
stdout,
terminal::Clear(terminal::ClearType::UntilNewLine),
Print("\r\n"),
)?;
stdout.flush()?;
Ok(input)
}
+747
View File
@@ -0,0 +1,747 @@
//! Interactive terminal prompts sharing one raw-mode event loop: free-text
//! input with an optional validator, one-line option selection and yes/no
//! confirmation.
//!
//! Every public prompt enables raw mode itself and always restores it. When
//! no interactive terminal is attached, [`select`] fails with
//! [`PromptError::NotATty`] so callers decide on a fallback, while [`text`]
//! and [`confirm`] answer with their default.
use crossterm::{
cursor, event, execute,
style::{self, Color, Print, SetForegroundColor},
terminal,
};
use std::io::{self, Write};
use std::time::Duration;
/// Why a prompt could not run interactively
#[derive(Debug)]
pub enum PromptError {
/// Raw mode could not be enabled: stdin is not an interactive terminal
NotATty,
}
impl std::fmt::Display for PromptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PromptError::NotATty => write!(f, "no interactive terminal available"),
}
}
}
impl std::error::Error for PromptError {}
/// Answer validator of [`text`]: accepts the answer, or explains why it is
/// rejected
pub type Validator = dyn Fn(&str) -> Result<(), String>;
/// What a prompt's event loop asks its drawing helper to render; keeping all
/// terminal access behind this callback makes the loops pure logic that unit
/// tests can drive with synthetic key events.
enum Render<'a> {
/// Redraw the prompt line showing this input
Line(&'a str),
/// Print a rejection message on its own line below the prompt
Rejected(&'a str),
/// Keep the line, clear any trailing characters and move to the next
/// line: the prompt was answered
Done,
/// Keep the line and move to the next line: the prompt was cancelled
/// with Ctrl+C
Cancelled,
}
/// Interactive one-line selector: ↑/↓ cycle through `options`, typing edits
/// the answer directly, Tab cycles through the options matching the current
/// input prefix, Enter confirms and Esc restores `default`. Ctrl+C cancels.
///
/// The line is rendered as `> <label><choice>`. With no options there is
/// nothing to choose and `default` is returned unchanged; without an
/// interactive terminal the call fails with [`PromptError::NotATty`] so the
/// caller decides on a fallback (see [`super::select_series`]).
pub fn select(
label: &str,
options: &[String],
default: &str,
) -> Result<String, Box<dyn std::error::Error>> {
if options.is_empty() {
return Ok(default.to_string());
}
with_raw_mode(|| {
select_inner(
options,
default,
terminal_events,
renderer(format!("> {label}")),
)
})
}
/// Single-line free-text input: printable characters append, Backspace
/// deletes, Enter confirms, Esc restores `default` and Ctrl+C cancels.
///
/// The line is rendered as `> <label> [<default>]: `. When `validator`
/// rejects an answer, its error is printed below the prompt and the question
/// is asked again; Esc and the non-TTY fallback skip validation.
pub fn text(
label: &str,
default: &str,
validator: Option<&Validator>,
) -> Result<String, Box<dyn std::error::Error>> {
let prefix = text_prefix(label, default);
match with_raw_mode(|| {
text_inner(
default,
validator,
terminal_events,
renderer(prefix.clone()),
)
}) {
// Not a TTY: the answer is the default, unvalidated
Err(err) if err.is::<PromptError>() => {
println!("{prefix}{default}");
Ok(default.to_string())
}
other => other,
}
}
/// Yes/no confirmation: answers y/yes/n/no (case-insensitive), Enter with an
/// empty answer or Esc takes `default`, Ctrl+C cancels.
///
/// The line is rendered as `> <label> [Y/n] `, the capital letter marking the
/// default answer. Without an interactive terminal the default is taken.
pub fn confirm(label: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
let prefix = confirm_prefix(label, default);
match with_raw_mode(|| confirm_inner(default, terminal_events, renderer(prefix.clone()))) {
// Not a TTY: the answer is the default
Err(err) if err.is::<PromptError>() => {
println!("{prefix}{}", if default { 'y' } else { 'n' });
Ok(default)
}
other => other,
}
}
/// Run `prompt` with the terminal in raw mode, always restoring it
/// afterwards. Fails with [`PromptError::NotATty`] when raw mode cannot be
/// enabled.
fn with_raw_mode<T>(
prompt: impl FnOnce() -> Result<T, Box<dyn std::error::Error>>,
) -> Result<T, Box<dyn std::error::Error>> {
// Try to enter raw mode — if not possible (e.g. piped input), the caller
// falls back to a non-interactive answer
if terminal::enable_raw_mode().is_err() {
return Err(Box::new(PromptError::NotATty));
}
let result = prompt();
// Always restore the terminal
let _ = terminal::disable_raw_mode();
result
}
/// Real key source: wait up to 200 ms for the next terminal event, `None`
/// when nothing arrived (the event loop keeps polling)
fn terminal_events() -> io::Result<Option<event::Event>> {
if event::poll(Duration::from_millis(200))? {
Ok(Some(event::read()?))
} else {
Ok(None)
}
}
/// Drawing helper for a prompt whose line is `prefix` followed by the current
/// input, rendered in cyan
fn renderer(prefix: String) -> impl FnMut(Render<'_>) -> io::Result<()> {
let mut stdout = io::stdout();
move |action| {
match action {
Render::Line(input) => {
execute!(
stdout,
cursor::MoveToColumn(0),
Print(&prefix),
SetForegroundColor(Color::Cyan),
Print(input),
style::ResetColor,
terminal::Clear(terminal::ClearType::UntilNewLine),
)?;
stdout.flush()?;
}
Render::Rejected(message) => {
execute!(
stdout,
Print("\r\n"),
SetForegroundColor(Color::Red),
Print(message),
style::ResetColor,
Print("\r\n"),
)?;
stdout.flush()?;
}
Render::Done => {
execute!(
stdout,
terminal::Clear(terminal::ClearType::UntilNewLine),
Print("\r\n"),
)?;
stdout.flush()?;
}
Render::Cancelled => {
execute!(stdout, Print("\r\n"))?;
stdout.flush()?;
}
}
Ok(())
}
}
/// Prompt line of [`text`]: `> label [default]: `, without the brackets when
/// there is no default to show
fn text_prefix(label: &str, default: &str) -> String {
if default.is_empty() {
format!("> {label}: ")
} else {
format!("> {label} [{default}]: ")
}
}
/// Prompt line of [`confirm`]: `> label [Y/n] `, the capital letter marking
/// the default answer
fn confirm_prefix(label: &str, default: bool) -> String {
let hint = if default { "Y/n" } else { "y/N" };
format!("> {label} [{hint}] ")
}
/// Event loop of [`select`]: pure logic over `next_event`, rendering through
/// `render` (see [`Render`])
fn select_inner(
options: &[String],
default: &str,
mut next_event: impl FnMut() -> io::Result<Option<event::Event>>,
mut render: impl FnMut(Render<'_>) -> io::Result<()>,
) -> Result<String, Box<dyn std::error::Error>> {
let default_idx = options.iter().position(|o| o == default).unwrap_or(0);
let mut selected_idx = default_idx;
let mut input = default.to_string();
// Whether we are in "navigation mode" (last action was arrow/tab selecting an option)
// vs "typing mode" (last action was typing a character)
let mut navigating = true;
render(Render::Line(&input))?;
loop {
let Some(event::Event::Key(event::KeyEvent {
code, modifiers, ..
})) = next_event()?
else {
continue;
};
match code {
event::KeyCode::Up => {
navigating = true;
if selected_idx > 0 {
selected_idx -= 1;
} else {
selected_idx = options.len() - 1;
}
input = options[selected_idx].clone();
render(Render::Line(&input))?;
}
event::KeyCode::Down => {
navigating = true;
if selected_idx < options.len() - 1 {
selected_idx += 1;
} else {
selected_idx = 0;
}
input = options[selected_idx].clone();
render(Render::Line(&input))?;
}
event::KeyCode::Enter => {
break;
}
event::KeyCode::Esc => {
input = default.to_string();
break;
}
event::KeyCode::Char(c) => {
if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' {
// Move to a new line before returning so the terminal isn't messed up
render(Render::Cancelled)?;
return Err("Cancelled".into());
}
navigating = false;
input.push(c);
// Auto-select if the input exactly matches an option
if let Some(idx) = options.iter().position(|o| o == &input) {
selected_idx = idx;
navigating = true;
}
render(Render::Line(&input))?;
}
event::KeyCode::Backspace => {
if !input.is_empty() {
input.pop();
navigating = false;
if let Some(idx) = options.iter().position(|o| o == &input) {
selected_idx = idx;
navigating = true;
}
render(Render::Line(&input))?;
}
}
event::KeyCode::Tab => {
// Cycle through options that start with the current input
let matches: Vec<usize> = options
.iter()
.enumerate()
.filter(|(_, o)| o.starts_with(&input))
.map(|(i, _)| i)
.collect();
if !matches.is_empty() {
let next = if navigating {
matches
.iter()
.find(|&&i| i > selected_idx)
.or_else(|| matches.first())
} else {
matches.first()
};
if let Some(&idx) = next {
selected_idx = idx;
input = options[idx].clone();
navigating = true;
}
render(Render::Line(&input))?;
}
}
_ => {}
}
}
// Move to a new line so subsequent output doesn't overwrite the prompt
render(Render::Done)?;
Ok(input)
}
/// Event loop of [`text`]: pure logic over `next_event`, rendering through
/// `render` (see [`Render`])
fn text_inner(
default: &str,
validator: Option<&Validator>,
mut next_event: impl FnMut() -> io::Result<Option<event::Event>>,
mut render: impl FnMut(Render<'_>) -> io::Result<()>,
) -> Result<String, Box<dyn std::error::Error>> {
let mut input = String::new();
render(Render::Line(&input))?;
loop {
let Some(event::Event::Key(event::KeyEvent {
code, modifiers, ..
})) = next_event()?
else {
continue;
};
match code {
event::KeyCode::Enter => {
if let Some(validator) = validator
&& let Err(message) = validator(&input)
{
render(Render::Rejected(&message))?;
render(Render::Line(&input))?;
continue;
}
break;
}
event::KeyCode::Esc => {
input = default.to_string();
break;
}
event::KeyCode::Char(c) => {
if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' {
render(Render::Cancelled)?;
return Err("Cancelled".into());
}
input.push(c);
render(Render::Line(&input))?;
}
event::KeyCode::Backspace => {
if !input.is_empty() {
input.pop();
render(Render::Line(&input))?;
}
}
_ => {}
}
}
render(Render::Done)?;
Ok(input)
}
/// Event loop of [`confirm`]: pure logic over `next_event`, rendering through
/// `render` (see [`Render`])
fn confirm_inner(
default: bool,
mut next_event: impl FnMut() -> io::Result<Option<event::Event>>,
mut render: impl FnMut(Render<'_>) -> io::Result<()>,
) -> Result<bool, Box<dyn std::error::Error>> {
let mut input = String::new();
render(Render::Line(&input))?;
let answer = loop {
let Some(event::Event::Key(event::KeyEvent {
code, modifiers, ..
})) = next_event()?
else {
continue;
};
match code {
event::KeyCode::Enter => match input.to_lowercase().as_str() {
"" => break default,
"y" | "yes" => break true,
"n" | "no" => break false,
_ => {
render(Render::Rejected("Please answer y/yes or n/no"))?;
input.clear();
render(Render::Line(&input))?;
}
},
event::KeyCode::Esc => break default,
event::KeyCode::Char(c) => {
if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' {
render(Render::Cancelled)?;
return Err("Cancelled".into());
}
input.push(c);
render(Render::Line(&input))?;
}
event::KeyCode::Backspace => {
if !input.is_empty() {
input.pop();
render(Render::Line(&input))?;
}
}
_ => {}
}
};
render(Render::Done)?;
Ok(answer)
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
/// Key press with no modifiers
fn key(code: KeyCode) -> event::Event {
event::Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
/// Ctrl+C key press
fn ctrl_c() -> event::Event {
event::Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
}
/// Event source replaying `events`, erroring once exhausted so a missing
/// Enter/Esc/Ctrl+C fails the test instead of hanging it
fn replay(events: Vec<event::Event>) -> impl FnMut() -> io::Result<Option<event::Event>> {
let mut events = events.into_iter();
move || match events.next() {
Some(event) => Ok(Some(event)),
None => Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"no more events",
)),
}
}
/// Drawing helper ignoring all rendering
fn nop_render(_: Render<'_>) -> io::Result<()> {
Ok(())
}
#[test]
fn select_down_cycles_with_wraparound() {
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
// a → b → c → wraps back to a
let mut events = vec![key(KeyCode::Down); 3];
events.push(key(KeyCode::Enter));
let selected = select_inner(&options, "a", replay(events), nop_render).unwrap();
assert_eq!(selected, "a");
}
#[test]
fn select_up_wraps_to_last_option() {
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let events = vec![key(KeyCode::Up), key(KeyCode::Enter)];
let selected = select_inner(&options, "a", replay(events), nop_render).unwrap();
assert_eq!(selected, "c");
}
#[test]
fn select_exact_match_autoselects() {
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
// Clear the default ("a"), type "b": it exactly matches an option and
// auto-selects it, so the following Down starts from "b"
let events = vec![
key(KeyCode::Backspace),
key(KeyCode::Char('b')),
key(KeyCode::Down),
key(KeyCode::Enter),
];
let selected = select_inner(&options, "a", replay(events), nop_render).unwrap();
assert_eq!(selected, "c");
}
#[test]
fn select_tab_walks_prefix_chain() {
let options = vec!["n".to_string(), "no".to_string(), "noble".to_string()];
// Tab cycles through the options matching the current input:
// "n" → "no" → "noble"
let events = vec![key(KeyCode::Tab), key(KeyCode::Tab), key(KeyCode::Enter)];
let selected = select_inner(&options, "n", replay(events), nop_render).unwrap();
assert_eq!(selected, "noble");
}
#[test]
fn select_tab_wraps_around_matches() {
// "a" (the default) is the last option matching the prefix "a", so
// Tab wraps back to the first one
let options = vec!["ab".to_string(), "a".to_string()];
let events = vec![key(KeyCode::Tab), key(KeyCode::Enter)];
let selected = select_inner(&options, "a", replay(events), nop_render).unwrap();
assert_eq!(selected, "ab");
}
#[test]
fn select_tab_picks_first_match_after_typing() {
let options = vec!["n".to_string(), "no".to_string(), "noble".to_string()];
// Clear the default ("noble"), type "n" (auto-selects "n"): the next
// Tab takes the following match of the prefix
let mut events = vec![key(KeyCode::Backspace); 5];
events.extend([
key(KeyCode::Char('n')),
key(KeyCode::Tab),
key(KeyCode::Enter),
]);
let selected = select_inner(&options, "noble", replay(events), nop_render).unwrap();
assert_eq!(selected, "no");
}
#[test]
fn select_esc_returns_default() {
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let events = vec![
key(KeyCode::Char('x')),
key(KeyCode::Char('y')),
key(KeyCode::Esc),
];
let selected = select_inner(&options, "b", replay(events), nop_render).unwrap();
assert_eq!(selected, "b");
}
#[test]
fn select_ignores_unknown_keys() {
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let events = vec![
key(KeyCode::Left),
key(KeyCode::Right),
key(KeyCode::Home),
key(KeyCode::End),
key(KeyCode::Delete),
key(KeyCode::PageUp),
key(KeyCode::F(1)),
event::Event::Resize(80, 24),
key(KeyCode::Enter),
];
let selected = select_inner(&options, "jammy", replay(events), nop_render).unwrap();
assert_eq!(selected, "jammy");
}
#[test]
fn select_ctrl_c_cancels() {
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let events = vec![key(KeyCode::Char('x')), ctrl_c()];
let err = select_inner(&options, "a", replay(events), nop_render).unwrap_err();
assert_eq!(err.to_string(), "Cancelled");
}
#[test]
fn text_appends_characters() {
let events = vec![
key(KeyCode::Char('h')),
key(KeyCode::Char('i')),
key(KeyCode::Enter),
];
let answer = text_inner("0.1.0", None, replay(events), nop_render).unwrap();
assert_eq!(answer, "hi");
}
#[test]
fn text_backspace_deletes() {
// Backspace on an empty line is a no-op, then "ab" → Backspace → "a"
let events = vec![
key(KeyCode::Backspace),
key(KeyCode::Char('a')),
key(KeyCode::Char('b')),
key(KeyCode::Backspace),
key(KeyCode::Enter),
];
let answer = text_inner("0.1.0", None, replay(events), nop_render).unwrap();
assert_eq!(answer, "a");
}
#[test]
fn text_esc_returns_default() {
let events = vec![key(KeyCode::Char('x')), key(KeyCode::Esc)];
let answer = text_inner("0.1.0", None, replay(events), nop_render).unwrap();
assert_eq!(answer, "0.1.0");
}
/// Validator only accepting the literal answer "ok"
fn require_ok(answer: &str) -> Result<(), String> {
if answer == "ok" {
Ok(())
} else {
Err(format!("not ok: {answer}"))
}
}
#[test]
fn text_validator_rejects_then_accepts() {
// "no" is rejected (the loop keeps going), then edited into "ok"
let events = vec![
key(KeyCode::Char('n')),
key(KeyCode::Char('o')),
key(KeyCode::Enter),
key(KeyCode::Backspace),
key(KeyCode::Backspace),
key(KeyCode::Char('o')),
key(KeyCode::Char('k')),
key(KeyCode::Enter),
];
let answer = text_inner("0.1.0", Some(&require_ok), replay(events), nop_render).unwrap();
assert_eq!(answer, "ok");
}
#[test]
fn text_ctrl_c_cancels() {
let events = vec![key(KeyCode::Char('x')), ctrl_c()];
let err = text_inner("0.1.0", None, replay(events), nop_render).unwrap_err();
assert_eq!(err.to_string(), "Cancelled");
}
#[test]
fn confirm_y_and_yes_agree() {
let yes = vec![key(KeyCode::Char('y')), key(KeyCode::Enter)];
let yes_spelled = vec![
key(KeyCode::Char('Y')),
key(KeyCode::Char('e')),
key(KeyCode::Char('S')),
key(KeyCode::Enter),
];
assert!(confirm_inner(false, replay(yes), nop_render).unwrap());
assert!(confirm_inner(false, replay(yes_spelled), nop_render).unwrap());
}
#[test]
fn confirm_n_and_no_deny() {
let no = vec![key(KeyCode::Char('n')), key(KeyCode::Enter)];
let no_spelled = vec![
key(KeyCode::Char('N')),
key(KeyCode::Char('o')),
key(KeyCode::Enter),
];
assert!(!confirm_inner(true, replay(no), nop_render).unwrap());
assert!(!confirm_inner(true, replay(no_spelled), nop_render).unwrap());
}
#[test]
fn confirm_enter_takes_default() {
let events = vec![key(KeyCode::Enter)];
assert!(confirm_inner(true, replay(events.clone()), nop_render).unwrap());
assert!(!confirm_inner(false, replay(events), nop_render).unwrap());
}
#[test]
fn confirm_esc_takes_default() {
// A typed answer is discarded by Esc
let events = vec![key(KeyCode::Char('n')), key(KeyCode::Esc)];
assert!(confirm_inner(true, replay(events), nop_render).unwrap());
}
#[test]
fn confirm_invalid_answer_reprompts() {
// "x" is rejected, the loop continues and "y" is accepted
let events = vec![
key(KeyCode::Char('x')),
key(KeyCode::Enter),
key(KeyCode::Char('y')),
key(KeyCode::Enter),
];
assert!(confirm_inner(false, replay(events), nop_render).unwrap());
}
#[test]
fn confirm_ctrl_c_cancels() {
let events = vec![ctrl_c()];
let err = confirm_inner(true, replay(events), nop_render).unwrap_err();
assert_eq!(err.to_string(), "Cancelled");
}
}