Files
pkh/src/new/debian.rs
T
vhaudiquet d64e472845 report: grow the Prompter port and move display_path out of the ui module
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.
2026-09-18 20:34:33 +02:00

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);
}
}