new: scaffold new Debian source packages (non-interactive core)

This commit is contained in:
2026-09-16 12:14:09 +02:00
parent 9c3394750d
commit d044f757e9
12 changed files with 3243 additions and 1 deletions
+7 -1
View File
@@ -259,7 +259,13 @@ fn prepend_to_file(path: &Path, content: &str) -> Result<(), Box<dyn std::error:
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
if let (Ok(name), Ok(email)) = (std::env::var("DEBFULLNAME"), std::env::var("DEBEMAIL")) {
return Ok((name, email));
+2
View File
@@ -16,6 +16,8 @@ pub mod deb;
pub mod debian;
/// Obtain general information about distribution, series, etc
pub mod distro_info;
/// Scaffold a new Debian source package (`pkh new`)
pub mod new;
/// Obtain information about one or multiple packages
pub mod package_info;
/// Prune residual pkh build artifacts and caches
+149
View File
@@ -35,6 +35,105 @@ fn main() {
let matches = command!()
.subcommand_required(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(
Command::new("pull")
.about("Pull a source package from the archive or git")
@@ -134,6 +233,56 @@ fn main() {
.get_matches();
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)) => {
let package = sub_matches.get_one::<String>("package").expect("required");
let series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
+730
View File
@@ -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);
}
}
+206
View File
@@ -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);
}
}
+64
View File
@@ -0,0 +1,64 @@
//! Git handling for `pkh new`: initialize a repository in the scaffolded
//! tree unless it is already inside one (the `.gitignore`s are written
//! regardless).
use std::path::Path;
/// Ensure `dir` has a git repository when it should: when `dir` is already
/// inside a work tree (its own or a parent's), nothing is initialized and
/// `Ok(false)` is returned with an info log; otherwise a repository is
/// initialized in `dir` when `init` is set (the `--no-git` case passes
/// `init = false`).
pub fn ensure_repository(dir: &Path, init: bool) -> Result<bool, Box<dyn std::error::Error>> {
match git2::Repository::discover(dir) {
Ok(_) => {
log::info!(
"Already inside a git repository; skipping git init \
(the .gitignore files are written anyway)"
);
Ok(false)
}
Err(_) if init => {
git2::Repository::init(dir)?;
log::info!("Initialized empty git repository in {}", dir.display());
Ok(true)
}
// --no-git: gitignores only.
Err(_) => Ok(false),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn init_skipped_with_no_git() {
let dir = tempdir().unwrap();
assert!(!ensure_repository(dir.path(), false).unwrap());
assert!(!dir.path().join(".git").exists());
}
#[test]
fn init_creates_repository() {
let dir = tempdir().unwrap();
assert!(ensure_repository(dir.path(), true).unwrap());
assert!(dir.path().join(".git").exists());
// A second call discovers the fresh repository and skips init.
assert!(!ensure_repository(dir.path(), true).unwrap());
}
#[test]
fn parent_repository_is_discovered() {
let dir = tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir_all(&sub).unwrap();
git2::Repository::init(dir.path()).unwrap();
// The subdirectory is already inside the parent work tree.
assert!(!ensure_repository(&sub, true).unwrap());
assert!(!sub.join(".git").exists());
}
}
+589
View File
@@ -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);
}
}
+943
View File
@@ -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}");
}
}
+84
View File
@@ -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"));
}
}
+164
View File
@@ -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()
);
}
}
+91
View File
@@ -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");
}
}
+214
View File
@@ -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}");
}
}