new: scaffold new Debian source packages (non-interactive core)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user