new: scaffold new Debian source packages (non-interactive core)
This commit is contained in:
+7
-1
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+149
@@ -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,56 @@ fn main() {
|
|||||||
.get_matches();
|
.get_matches();
|
||||||
|
|
||||||
match matches.subcommand() {
|
match matches.subcommand() {
|
||||||
|
Some(("new", sub_matches)) => {
|
||||||
|
// Without the interactive wizard (follow-up work), missing
|
||||||
|
// required answers produce one error listing all of them;
|
||||||
|
// --defaults fills everything else from the defaults.
|
||||||
|
let depends: Vec<String> = sub_matches
|
||||||
|
.get_many::<String>("depends")
|
||||||
|
.map(|values| values.cloned().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let 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),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = rt.block_on(async {
|
||||||
|
let opts = pkh::new::options::resolve(cli).await?;
|
||||||
|
pkh::new::scaffold(opts, &multi)
|
||||||
|
}) {
|
||||||
|
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());
|
||||||
|
|||||||
@@ -0,0 +1,730 @@
|
|||||||
|
//! 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(template),
|
||||||
|
copyright(opts),
|
||||||
|
debian_gitignore(opts),
|
||||||
|
];
|
||||||
|
if !opts.native {
|
||||||
|
files.push(local_options());
|
||||||
|
}
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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));
|
||||||
|
|
||||||
|
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()));
|
||||||
|
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 minimal `dh $@` makefile (plus the template's extra
|
||||||
|
/// overrides, when any), written with the executable bit.
|
||||||
|
fn rules(template: &dyn Template) -> OutputFile {
|
||||||
|
let mut contents = String::from("#!/usr/bin/make -f\n%:\n\tdh $@\n");
|
||||||
|
let extra = template.rules_extra();
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 rules = super::rules(crate::new::templates::get(TemplateId::Shell).unwrap());
|
||||||
|
assert!(rules.executable);
|
||||||
|
assert_eq!(rules.contents, "#!/usr/bin/make -f\n%:\n\tdh $@\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
//! 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 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.
|
||||||
|
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"#!")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+589
@@ -0,0 +1,589 @@
|
|||||||
|
//! `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), orig
|
||||||
|
//! tarball creation, git initialization, structural verification and the
|
||||||
|
//! next-steps message. The interactive wizard and the remaining templates
|
||||||
|
//! (python, meson, cmake, autotools, go, rust, makefile) are follow-up work
|
||||||
|
//! built on top of the same [`options::NewOptions`] and
|
||||||
|
//! [`templates::Template`] surface.
|
||||||
|
|
||||||
|
pub mod debian;
|
||||||
|
pub mod detect;
|
||||||
|
pub mod git;
|
||||||
|
pub mod options;
|
||||||
|
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 (unimplemented ids fail here, not at parse time),
|
||||||
|
/// 2. check the target directory (refuse an existing `debian/control`),
|
||||||
|
/// 3. render every file in memory and check for collisions,
|
||||||
|
/// 4. write the files all-or-nothing (plus the root `.gitignore` in skeleton
|
||||||
|
/// mode, appending to an existing one),
|
||||||
|
/// 5. create the orig tarball (quilt only, refusing overwrites),
|
||||||
|
/// 6. `git init` unless `--no-git` or already inside a repository,
|
||||||
|
/// 7. run the structural verification,
|
||||||
|
/// 8. print the success message with the next steps.
|
||||||
|
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: unknown-but-valid ids fail here with the
|
||||||
|
// friendly message instead of a parse error.
|
||||||
|
let template = templates::get(opts.template).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"The '{}' template is not implemented yet. Implemented templates: {}.",
|
||||||
|
opts.template,
|
||||||
|
templates::all()
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.id().as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 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. Orig tarball (quilt only).
|
||||||
|
if !opts.native {
|
||||||
|
pb.set_message("Creating orig tarball");
|
||||||
|
debian::create_orig_tarball(&target, &opts.name, &opts.upstream_version_no_epoch())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Git.
|
||||||
|
pb.set_message("Initializing git");
|
||||||
|
git::ensure_repository(&target, opts.git)?;
|
||||||
|
|
||||||
|
// 7. 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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}");
|
||||||
|
|
||||||
|
// Unimplemented template.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let err = scaffold_in(
|
||||||
|
dir.path(),
|
||||||
|
opts(TemplateId::Rust, "mytool", SourceDir::Skeleton),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("not implemented yet"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[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: 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,943 @@
|
|||||||
|
//! 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,
|
||||||
|
/// even the ones that have no template implementation yet (those fail at
|
||||||
|
/// scaffold time with a "not implemented yet" error instead of a parse
|
||||||
|
/// error, so scripts written today keep working once they land).
|
||||||
|
#[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(", ")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a template implementation (and therefore scaffolding) exists
|
||||||
|
/// for this id. The remaining templates land with the follow-up work.
|
||||||
|
pub fn implemented(&self) -> bool {
|
||||||
|
matches!(self, TemplateId::Shell | TemplateId::Empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 answer interactively once the wizard lands):\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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
assert!(TemplateId::parse("cobol").is_err());
|
||||||
|
// Every id is accepted by the parser; only some are implemented.
|
||||||
|
assert!(!TemplateId::Rust.implemented());
|
||||||
|
assert!(TemplateId::Shell.implemented());
|
||||||
|
assert!(TemplateId::Empty.implemented());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
};
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(), "all");
|
||||||
|
assert!(template.build_depends(&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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//! Per-ecosystem template registry for `pkh new`.
|
||||||
|
//!
|
||||||
|
//! Every template implements [`Template`]: it renders the upstream-side
|
||||||
|
//! skeleton files, the extra `debian/` files beyond the common set, and (in
|
||||||
|
//! the future) probes an existing project for metadata. Rendering is plain
|
||||||
|
//! `format!` composition — no template engine, matching the codebase style.
|
||||||
|
|
||||||
|
pub mod empty;
|
||||||
|
pub mod shell;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::options::{NewOptions, 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). The per-template extraction is follow-up work; the hook already
|
||||||
|
/// exists so templates can grow it independently.
|
||||||
|
#[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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) -> &'static str {
|
||||||
|
"all"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lines appended to `debian/rules` after the default `dh $@` stanza.
|
||||||
|
fn rules_extra(&self) -> String {
|
||||||
|
String::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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// Every implemented template (the wizard language menu lists
|
||||||
|
/// [`TemplateId::all()`] and greys the rest out).
|
||||||
|
static TEMPLATES: &[&dyn Template] = &[&SHELL, &EMPTY];
|
||||||
|
|
||||||
|
/// Look up the template implementation for `id`; `None` for the ids whose
|
||||||
|
/// template is not implemented yet (callers turn this into the friendly
|
||||||
|
/// "not implemented yet" error).
|
||||||
|
pub fn get(id: TemplateId) -> Option<&'static dyn Template> {
|
||||||
|
match id {
|
||||||
|
TemplateId::Shell => Some(&SHELL),
|
||||||
|
TemplateId::Empty => Some(&EMPTY),
|
||||||
|
TemplateId::Rust
|
||||||
|
| TemplateId::Python
|
||||||
|
| TemplateId::Meson
|
||||||
|
| TemplateId::Cmake
|
||||||
|
| TemplateId::Autotools
|
||||||
|
| TemplateId::Go
|
||||||
|
| TemplateId::Makefile => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every implemented template.
|
||||||
|
pub fn all() -> &'static [&'static dyn Template] {
|
||||||
|
TEMPLATES
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_covers_implemented_templates() {
|
||||||
|
for id in [TemplateId::Shell, TemplateId::Empty] {
|
||||||
|
assert!(get(id).is_some(), "{id} must be registered");
|
||||||
|
assert_eq!(get(id).unwrap().id(), id);
|
||||||
|
}
|
||||||
|
for id in [
|
||||||
|
TemplateId::Rust,
|
||||||
|
TemplateId::Python,
|
||||||
|
TemplateId::Meson,
|
||||||
|
TemplateId::Cmake,
|
||||||
|
TemplateId::Autotools,
|
||||||
|
TemplateId::Go,
|
||||||
|
TemplateId::Makefile,
|
||||||
|
] {
|
||||||
|
assert!(get(id).is_none(), "{id} must not pretend to be implemented");
|
||||||
|
}
|
||||||
|
assert_eq!(all().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_defaults_to_none() {
|
||||||
|
assert!(
|
||||||
|
get(TemplateId::Shell)
|
||||||
|
.unwrap()
|
||||||
|
.probe(Path::new("/"))
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
//! The `shell` template: a single interpreted script installed to
|
||||||
|
//! `/usr/bin` with plain `dh $@` plumbing.
|
||||||
|
|
||||||
|
use super::{OutputFile, Template};
|
||||||
|
use crate::new::options::{NewOptions, SourceDir};
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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".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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_template_shape() {
|
||||||
|
let o = opts();
|
||||||
|
let template = super::super::get(TemplateId::Shell).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(template.architecture(), "all");
|
||||||
|
assert!(template.build_depends(&o).is_empty());
|
||||||
|
assert!(template.rules_extra().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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
//! 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user