Prompter gains interactive(), select() and text() (with the Validator type), and confirm() now propagates cancellation as Err so flows abort instead of silently taking a default when the user hits Ctrl+C. The terminal prompter implements the full port; the port also re-exports the path display helper, which is pure presentation formatting used by events and messages rather than terminal code.
947 lines
33 KiB
Rust
947 lines
33 KiB
Rust
//! 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, SourceFormat};
|
|
use super::templates::{OutputFile, Template};
|
|
|
|
/// `3.0 (quilt)` source format, the default when packaging an existing
|
|
/// project.
|
|
pub const SOURCE_FORMAT_QUILT: &str = "3.0 (quilt)";
|
|
/// `3.0 (native)` source format, the default for a fresh skeleton.
|
|
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"];
|
|
|
|
/// Current Debian Policy version, written as the `Standards-Version` of the
|
|
/// generated `debian/control` (mandatory in the source stanza per policy).
|
|
/// Bump as policy evolves.
|
|
pub const DEBIAN_POLICY_VERSION: &str = "4.7.4";
|
|
|
|
/// 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: &Template) -> Vec<OutputFile> {
|
|
let mut files = vec![
|
|
source_format(opts),
|
|
changelog(opts),
|
|
control(opts, template),
|
|
rules(opts, template),
|
|
copyright(opts),
|
|
debian_gitignore(opts),
|
|
];
|
|
if opts.source_format == SourceFormat::Quilt {
|
|
files.push(local_options());
|
|
}
|
|
if opts.autopkgtest {
|
|
files.push(autopkgtest_control());
|
|
files.push(autopkgtest_smoke(opts));
|
|
}
|
|
if let Some(watch) = &opts.watch {
|
|
files.push(OutputFile::new("debian/watch", watch.clone()));
|
|
}
|
|
files
|
|
}
|
|
|
|
/// `debian/tests/control`: the autopkgtest smoke test definition.
|
|
fn autopkgtest_control() -> OutputFile {
|
|
OutputFile::new(
|
|
"debian/tests/control",
|
|
"Tests: smoke\nDepends: @\nRestrictions: allow-stderr\n",
|
|
)
|
|
}
|
|
|
|
/// `debian/tests/smoke`: run the installed command once; `--help` first,
|
|
/// `--version` as the fallback (some tools only answer one of them).
|
|
fn autopkgtest_smoke(opts: &NewOptions) -> OutputFile {
|
|
OutputFile::executable(
|
|
"debian/tests/smoke",
|
|
format!(
|
|
"#!/bin/sh\n\
|
|
set -e\n\
|
|
{command} --help >/dev/null 2>&1 || {command} --version\n",
|
|
command = opts.command,
|
|
),
|
|
)
|
|
}
|
|
|
|
/// `debian/source/format`: `3.0 (native)` for a skeleton by default,
|
|
/// `3.0 (quilt)` for an existing project; either can be forced with
|
|
/// `--native` / `--quilt`.
|
|
fn source_format(opts: &NewOptions) -> OutputFile {
|
|
OutputFile::new(
|
|
"debian/source/format",
|
|
format!("{}\n", opts.source_format.deb_string()),
|
|
)
|
|
}
|
|
|
|
/// `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). Common build-output
|
|
/// directories are excluded from the delta as well: compiling locally before
|
|
/// a source build must not turn `target/`, `node_modules/` or `.venv/`
|
|
/// binaries into unrepresentable changes (dpkg ignores `__pycache__` and
|
|
/// friends by default, but not those).
|
|
fn local_options() -> OutputFile {
|
|
OutputFile::new(
|
|
"debian/source/local-options",
|
|
"single-debian-patch\n\
|
|
extend-diff-ignore = ^target/\n\
|
|
extend-diff-ignore = ^node_modules/\n\
|
|
extend-diff-ignore = ^\\.venv/\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: &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");
|
|
control.push_str(&format!("Standards-Version: {DEBIAN_POLICY_VERSION}\n"));
|
|
|
|
let mut build_depends = vec!["debhelper-compat (= 13)".to_string()];
|
|
build_depends.extend(template.build_depends(opts));
|
|
control.push_str(&render_field("Build-Depends", &build_depends));
|
|
|
|
for (key, value) in template.source_fields(opts) {
|
|
control.push_str(&format!("{key}: {value}\n"));
|
|
}
|
|
|
|
if let Some(homepage) = &opts.homepage {
|
|
control.push_str(&format!("Homepage: {homepage}\n"));
|
|
}
|
|
|
|
control.push('\n');
|
|
|
|
// Binary stanza.
|
|
control.push_str(&format!("Package: {}\n", opts.name));
|
|
control.push_str(&format!("Architecture: {}\n", template.architecture(opts)));
|
|
if !opts.depends.is_empty() {
|
|
control.push_str(&render_field("Depends", &opts.depends));
|
|
}
|
|
control.push_str(&format!("Description: {}\n", opts.summary));
|
|
control.push_str(&render_continuation_text(&opts.long_description));
|
|
|
|
OutputFile::new("debian/control", control)
|
|
}
|
|
|
|
/// `debian/rules`: the shebang and `%:` target whose recipe is the
|
|
/// template's dh line (plus the template's extra overrides, when any),
|
|
/// written with the executable bit.
|
|
fn rules(opts: &NewOptions, template: &Template) -> OutputFile {
|
|
let mut contents = format!("#!/usr/bin/make -f\n%:\n\t{}\n", template.rules_dh_line());
|
|
let extra = template.rules_extra(opts);
|
|
if !extra.is_empty() {
|
|
contents.push('\n');
|
|
contents.push_str(&extra);
|
|
if !contents.ends_with('\n') {
|
|
contents.push('\n');
|
|
}
|
|
}
|
|
OutputFile::executable("debian/rules", contents)
|
|
}
|
|
|
|
/// Short license-reference paragraph embedded in `debian/copyright`.
|
|
fn license_reference_paragraph(license: &super::options::License) -> String {
|
|
use super::options::License;
|
|
match license {
|
|
License::Custom(s) if s.eq_ignore_ascii_case("unknown") => {
|
|
"The licensing terms of this package are not known yet. \
|
|
Replace this paragraph with a proper license reference."
|
|
.to_string()
|
|
}
|
|
License::Custom(s) => format!(
|
|
"The package is distributed under the terms of the '{s}' license. \
|
|
Replace this paragraph with the full license reference."
|
|
),
|
|
known => format!(
|
|
"The package is distributed under the terms of the {} license. \
|
|
The full license text is available at <{}>.",
|
|
known.spdx(),
|
|
known.spdx_url()
|
|
),
|
|
}
|
|
}
|
|
|
|
/// `debian/copyright` in the DEP-5 machine-readable format: header, the
|
|
/// `Files: *` stanza covering the current year, and a standalone license
|
|
/// stanza with a short reference paragraph.
|
|
fn copyright(opts: &NewOptions) -> OutputFile {
|
|
let year = chrono::Local::now().year();
|
|
let mut out = String::new();
|
|
|
|
out.push_str("Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n");
|
|
out.push_str(&format!("Upstream-Name: {}\n", opts.name));
|
|
if let Some(homepage) = &opts.homepage {
|
|
out.push_str(&format!("Source: {homepage}\n"));
|
|
}
|
|
out.push('\n');
|
|
|
|
out.push_str("Files: *\n");
|
|
out.push_str(&format!(
|
|
"Copyright: {} {} <{}>\n",
|
|
year, opts.maintainer.0, opts.maintainer.1
|
|
));
|
|
out.push_str(&format!("License: {}\n", opts.license.spdx()));
|
|
out.push_str(&render_continuation_text(&license_reference_paragraph(
|
|
&opts.license,
|
|
)));
|
|
out.push('\n');
|
|
|
|
out.push_str(&format!("License: {}\n", opts.license.spdx()));
|
|
out.push_str(&render_continuation_text(&license_reference_paragraph(
|
|
&opts.license,
|
|
)));
|
|
|
|
OutputFile::new("debian/copyright", out)
|
|
}
|
|
|
|
/// `debian/.gitignore`: the debhelper build artifacts. The patterns are
|
|
/// relative to `debian/` itself (a `debian/`-prefixed pattern would be
|
|
/// anchored to `debian/debian/` inside this file, per gitignore(5)).
|
|
fn debian_gitignore(opts: &NewOptions) -> OutputFile {
|
|
OutputFile::new(
|
|
"debian/.gitignore",
|
|
format!(
|
|
"files\n\
|
|
.debhelper/\n\
|
|
*.log\n\
|
|
{}/\n\
|
|
debhelper-build-stamp\n\
|
|
*.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/",
|
|
];
|
|
|
|
/// Comment heading a root `.gitignore` freshly created by pkh (the skeleton
|
|
/// build-artifact section).
|
|
pub const ROOT_GITIGNORE_HEADER: &str = "# pkh build artifacts";
|
|
|
|
/// Merge `entries` into the root `.gitignore` contents `existing` (the
|
|
/// current file contents, when there is one): missing entries are appended,
|
|
/// an existing file is never overwritten just to duplicate entries. A fresh
|
|
/// file is headed by the `header` comment when one is given; appending to a
|
|
/// user file adds bare entries. Returns the new contents, or `None` when
|
|
/// nothing has to be written.
|
|
pub fn merge_gitignore_entries(
|
|
existing: Option<&str>,
|
|
entries: &[&str],
|
|
header: Option<&str>,
|
|
) -> Option<String> {
|
|
let have: HashSet<&str> = existing
|
|
.map(|content| content.lines().map(str::trim).collect())
|
|
.unwrap_or_default();
|
|
let missing: Vec<&str> = 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()
|
|
&& let Some(header) = header
|
|
{
|
|
out.push_str(header);
|
|
out.push('\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>> {
|
|
create_orig_tarball_excluding(tree, name, upstream_version, false)
|
|
}
|
|
|
|
/// [`create_orig_tarball`] with the generated `vendor/` directory of a
|
|
/// vendored rust package excluded from the snapshot: its contents travel in
|
|
/// the separate `<name>_<uver>.orig-vendor.tar.xz` component instead (see
|
|
/// [`super::orig`]), so they can be regenerated independently of the
|
|
/// upstream sources.
|
|
pub fn create_orig_tarball_excluding(
|
|
tree: &Path,
|
|
name: &str,
|
|
upstream_version: &str,
|
|
exclude_vendor: bool,
|
|
) -> 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)?;
|
|
let top_excludes: &[&str] = if exclude_vendor { &["vendor"] } else { &[] };
|
|
append_tree(&mut builder, tree, &prefix, 0, ORIG_EXCLUDE, top_excludes)?;
|
|
|
|
builder
|
|
.finish()
|
|
.map_err(|e| format!("failed to write '{}': {}", tarball_path.display(), e))?;
|
|
|
|
log::info!(
|
|
"Created orig tarball {}",
|
|
crate::report::display_path(&tarball_path)
|
|
);
|
|
Ok(tarball_path)
|
|
}
|
|
|
|
/// Recursively append `dir` to the archive under `archive_path`, skipping
|
|
/// non-regular files, the names of `excludes` at any depth, the `debian/`
|
|
/// directory and the names of `top_excludes` at the top level (depth 0).
|
|
pub(crate) fn append_tree(
|
|
builder: &mut Builder<XzEncoder<std::fs::File>>,
|
|
dir: &Path,
|
|
archive_path: &str,
|
|
depth: usize,
|
|
excludes: &[&str],
|
|
top_excludes: &[&str],
|
|
) -> 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" || top_excludes.contains(&name.as_str())) {
|
|
continue;
|
|
}
|
|
if excludes.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,
|
|
excludes,
|
|
top_excludes,
|
|
)?;
|
|
} 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(),
|
|
source_format: SourceFormat::Quilt,
|
|
orig: None,
|
|
git: true,
|
|
autopkgtest: false,
|
|
pkg_config: false,
|
|
watch: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn source_format_and_local_options() {
|
|
let o = opts();
|
|
let files = super::files(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
|
let find = |path: &str| {
|
|
files
|
|
.iter()
|
|
.find(|f| f.path == path)
|
|
.unwrap_or_else(|| panic!("{path} missing"))
|
|
};
|
|
|
|
assert_eq!(find("debian/source/format").contents, "3.0 (quilt)\n");
|
|
assert_eq!(
|
|
find("debian/source/local-options").contents,
|
|
"single-debian-patch\n\
|
|
extend-diff-ignore = ^target/\n\
|
|
extend-diff-ignore = ^node_modules/\n\
|
|
extend-diff-ignore = ^\\.venv/\n"
|
|
);
|
|
|
|
let native = NewOptions {
|
|
source_format: SourceFormat::Native,
|
|
..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("Standards-Version"),
|
|
Some(DEBIAN_POLICY_VERSION)
|
|
);
|
|
assert_eq!(
|
|
parsed.source.get("Homepage"),
|
|
Some("https://example.com/mytool")
|
|
);
|
|
assert_eq!(parsed.binaries.len(), 1);
|
|
assert_eq!(parsed.binaries[0].get("Package"), Some("mytool"));
|
|
assert_eq!(parsed.binaries[0].get("Architecture"), Some("all"));
|
|
assert_eq!(
|
|
parsed.binaries[0].get("Description"),
|
|
Some("A tool that does one thing well\nA tool that does one thing well")
|
|
);
|
|
|
|
// Without homepage both the control Homepage field and the DEP-5
|
|
// Source field are absent (the stanza's leading `Source:` line is
|
|
// still there of course).
|
|
let o = NewOptions {
|
|
homepage: None,
|
|
..opts()
|
|
};
|
|
let control = super::control(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
|
assert!(!control.contents.contains("Homepage:"));
|
|
let parsed = crate::debian::ControlInfo::parse_content(&control.contents).unwrap();
|
|
assert!(parsed.source.get("Homepage").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn rules_is_executable_minimal_makefile() {
|
|
let o = opts();
|
|
let rules = super::rules(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
|
assert!(rules.executable);
|
|
assert_eq!(rules.contents, "#!/usr/bin/make -f\n%:\n\tdh $@\n");
|
|
}
|
|
|
|
#[test]
|
|
fn extra_files_autopkgtest_and_watch() {
|
|
let mut o = opts();
|
|
o.autopkgtest = true;
|
|
o.watch = Some(
|
|
"version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
|
.to_string(),
|
|
);
|
|
let files = super::files(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
|
let find = |path: &str| {
|
|
files
|
|
.iter()
|
|
.find(|f| f.path == path)
|
|
.unwrap_or_else(|| panic!("{path} missing"))
|
|
};
|
|
|
|
let control = find("debian/tests/control");
|
|
assert_eq!(
|
|
control.contents,
|
|
"Tests: smoke\nDepends: @\nRestrictions: allow-stderr\n"
|
|
);
|
|
let smoke = find("debian/tests/smoke");
|
|
assert!(smoke.executable);
|
|
assert!(smoke.contents.starts_with("#!/bin/sh\nset -e\n"));
|
|
assert!(
|
|
smoke
|
|
.contents
|
|
.contains("mytool --help >/dev/null 2>&1 || mytool --version")
|
|
);
|
|
|
|
assert_eq!(
|
|
find("debian/watch").contents,
|
|
"version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
|
);
|
|
|
|
// Without the extras none of the files are rendered.
|
|
let plain = super::files(
|
|
&opts(),
|
|
crate::new::templates::get(TemplateId::SHELL).unwrap(),
|
|
);
|
|
assert!(!plain.iter().any(|f| f.path.starts_with("debian/tests")));
|
|
assert!(!plain.iter().any(|f| f.path == "debian/watch"));
|
|
}
|
|
|
|
#[test]
|
|
fn copyright_is_dep5() {
|
|
let c = super::copyright(&opts());
|
|
assert!(c.contents.starts_with(
|
|
"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n"
|
|
));
|
|
assert!(c.contents.contains("Upstream-Name: mytool\n"));
|
|
assert!(c.contents.contains("Source: https://example.com/mytool\n"));
|
|
assert!(c.contents.contains("Files: *\n"));
|
|
assert!(c.contents.contains("License: MIT\n"));
|
|
assert!(c.contents.contains(&format!(
|
|
"Copyright: {} Jane Doe <jane@example.com>\n",
|
|
chrono::Local::now().year()
|
|
)));
|
|
assert!(c.contents.contains("https://spdx.org/licenses/MIT.html"));
|
|
|
|
// Unknown license: honest reference paragraph, still valid deb822.
|
|
let o = NewOptions {
|
|
license: License::Custom("unknown".into()),
|
|
..opts()
|
|
};
|
|
let c = super::copyright(&o);
|
|
assert!(c.contents.contains("not known yet"));
|
|
assert!(crate::debian::parse_paragraphs(&c.contents).len() >= 3);
|
|
}
|
|
|
|
#[test]
|
|
fn debian_gitignore_contents() {
|
|
let g = super::debian_gitignore(&opts());
|
|
assert_eq!(
|
|
g.contents,
|
|
"files\n.debhelper/\n*.log\nmytool/\n\
|
|
debhelper-build-stamp\n*.substvars\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gitignore_merge() {
|
|
// Fresh file: header + all entries.
|
|
let fresh =
|
|
merge_gitignore_entries(None, &ROOT_GITIGNORE_ENTRIES, Some(ROOT_GITIGNORE_HEADER))
|
|
.unwrap();
|
|
assert!(fresh.starts_with("# pkh build artifacts\n"));
|
|
for entry in ROOT_GITIGNORE_ENTRIES {
|
|
assert!(fresh.contains(entry), "{entry} missing");
|
|
}
|
|
|
|
// A fresh file without a header carries the bare entries.
|
|
assert_eq!(
|
|
merge_gitignore_entries(None, &["a/", "b"], None).unwrap(),
|
|
"a/\nb\n"
|
|
);
|
|
|
|
// Existing file: only the missing entries are appended, nothing lost.
|
|
let existing = "*.deb\nnode_modules/\n";
|
|
let merged = merge_gitignore_entries(
|
|
Some(existing),
|
|
&ROOT_GITIGNORE_ENTRIES,
|
|
Some(ROOT_GITIGNORE_HEADER),
|
|
)
|
|
.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_gitignore_entries(
|
|
Some(&full),
|
|
&ROOT_GITIGNORE_ENTRIES,
|
|
Some(ROOT_GITIGNORE_HEADER),
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
/// The vendoring entries of the rust template merge into an existing
|
|
/// user `.gitignore` like any other entry set: appended after the
|
|
/// user's lines, no header comment, idempotent.
|
|
#[test]
|
|
fn gitignore_merge_appends_template_entries() {
|
|
let entries = ["vendor/", ".cargo/config.toml"];
|
|
let merged =
|
|
merge_gitignore_entries(Some("# my project\n*.log\n"), &entries, None).unwrap();
|
|
assert_eq!(merged, "# my project\n*.log\nvendor/\n.cargo/config.toml\n");
|
|
|
|
// Already ignored: nothing to write.
|
|
assert!(
|
|
merge_gitignore_entries(Some("vendor/\n.cargo/config.toml\n"), &entries, None)
|
|
.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"));
|
|
}
|
|
|
|
/// The vendored-rust variant excludes the top-level `vendor/` (it
|
|
/// travels in the orig-vendor component) but keeps unrelated trees.
|
|
#[test]
|
|
fn orig_tarball_vendor_exclusion() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let tree = dir.path().join("mytool");
|
|
std::fs::create_dir_all(tree.join("vendor/serde/src")).unwrap();
|
|
std::fs::create_dir_all(tree.join("src/vendor")).unwrap();
|
|
std::fs::write(tree.join("vendor/serde/src/lib.rs"), "code").unwrap();
|
|
std::fs::write(tree.join("src/vendor/mod.rs"), "code").unwrap();
|
|
std::fs::write(tree.join("Cargo.toml"), "[package]").unwrap();
|
|
|
|
let tarball = create_orig_tarball_excluding(&tree, "mytool", "0.1.0", true).unwrap();
|
|
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
|
|
std::fs::File::open(&tarball).unwrap(),
|
|
));
|
|
let names: Vec<String> = archive
|
|
.entries()
|
|
.unwrap()
|
|
.map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
|
|
.collect();
|
|
// The generated vendored tree is out...
|
|
assert!(
|
|
!names.iter().any(|n| n.starts_with("mytool-0.1.0/vendor")),
|
|
"{names:?}"
|
|
);
|
|
// ...an unrelated nested vendor/ stays in...
|
|
assert!(
|
|
names.iter().any(|n| n == "mytool-0.1.0/src/vendor/mod.rs"),
|
|
"{names:?}"
|
|
);
|
|
// ...and normal files are unaffected.
|
|
assert!(
|
|
names.iter().any(|n| n == "mytool-0.1.0/Cargo.toml"),
|
|
"{names:?}"
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|