936 lines
36 KiB
Rust
936 lines
36 KiB
Rust
//! `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), the
|
|
//! root `.gitignore` merge (skeleton build artifacts plus the template's own
|
|
//! entries, e.g. the rust vendored layout), the template post-write hook
|
|
//! (e.g. `cargo vendor`), orig tarball creation
|
|
//! (from the origin the run decided on — see [`origin`] and [`orig`]), git
|
|
//! initialization, structural verification and the next-steps message.
|
|
//! The interactive wizard ([`questions`]) fills a [`options::NewCli`] from
|
|
//! its answers on a TTY and reuses [`options::resolve`] as the single source
|
|
//! of truth for defaults and validation; without a TTY the same resolution
|
|
//! runs flag-driven.
|
|
|
|
pub mod debian;
|
|
pub mod detect;
|
|
pub mod git;
|
|
/// License reference data for the scaffolder (bundled `data/licenses.yml`)
|
|
pub(crate) mod licenses;
|
|
pub mod options;
|
|
pub mod orig;
|
|
pub mod origin;
|
|
pub mod questions;
|
|
pub mod templates;
|
|
pub mod verify;
|
|
|
|
use std::error::Error;
|
|
use std::time::Duration;
|
|
|
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
|
|
|
use options::{NewOptions, SourceFormat};
|
|
use templates::{OutputFile, ScaffoldOutcome};
|
|
|
|
/// Scaffold a full Debian source tree from `opts`.
|
|
///
|
|
/// Steps, aborting early with a pointed error message:
|
|
/// 1. resolve the template from the registry,
|
|
/// 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` merge:
|
|
/// the skeleton build-artifact entries in skeleton mode and the
|
|
/// template's own entries — rust: the vendored layout — in every mode,
|
|
/// appending to an existing file),
|
|
/// 5. run the template post-write hook (e.g. `cargo vendor`, so the vendored
|
|
/// sources land inside the orig tarball created next),
|
|
/// 6. create the orig tarball (quilt only, refusing overwrites),
|
|
/// 7. `git init` unless `--no-git` or already inside a repository,
|
|
/// 8. run the structural verification,
|
|
/// 9. print the success message with the next steps.
|
|
///
|
|
/// On success the [`ScaffoldOutcome`] of the post-write hook is returned, so
|
|
/// the caller can adapt the end of the flow (e.g. the verification offer)
|
|
/// to what the templates managed to do.
|
|
pub fn scaffold(
|
|
opts: NewOptions,
|
|
multi: &MultiProgress,
|
|
) -> Result<ScaffoldOutcome, 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 let Ok(outcome) = &result {
|
|
print_success(&opts, outcome);
|
|
}
|
|
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<ScaffoldOutcome, Box<dyn Error>> {
|
|
// 1. Template resolution: an id without a registered template fails
|
|
// here with the friendly message instead of a parse error.
|
|
let template = templates::get(opts.template).ok_or_else(|| {
|
|
format!(
|
|
"The '{}' template has no registered implementation. \
|
|
This is a pkh bug; please report it.",
|
|
opts.template,
|
|
)
|
|
})?;
|
|
|
|
// 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.source_format == SourceFormat::Quilt
|
|
&& 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: the skeleton build-artifact entries in skeleton
|
|
// mode, plus the template's own entries (rust: the vendored layout) in
|
|
// every mode — missing ones appended, an existing file never
|
|
// overwritten.
|
|
let template_gitignore = template.gitignore_entries(opts);
|
|
let mut entries: Vec<&str> = Vec::new();
|
|
let mut header = None;
|
|
if skeleton {
|
|
entries.extend(debian::ROOT_GITIGNORE_ENTRIES);
|
|
header = Some(debian::ROOT_GITIGNORE_HEADER);
|
|
}
|
|
entries.extend(template_gitignore.iter().map(String::as_str));
|
|
if !entries.is_empty()
|
|
&& let Some(contents) = debian::merge_gitignore_entries(
|
|
std::fs::read_to_string(target.join(".gitignore"))
|
|
.ok()
|
|
.as_deref(),
|
|
&entries,
|
|
header,
|
|
)
|
|
{
|
|
std::fs::write(target.join(".gitignore"), contents)?;
|
|
}
|
|
|
|
// 5. Template post-write hook: run before the orig tarball is created,
|
|
// so files added here (rust: vendor/ + .cargo/config.toml) land
|
|
// inside it (or inside the orig-vendor component). The outcome
|
|
// (e.g. a failed vendoring) is threaded back to the caller.
|
|
pb.set_message("Running template hooks");
|
|
let mut outcome = template.post_write(opts, &target)?;
|
|
|
|
// 6. Orig tarball (quilt only), from the origin the run decided on.
|
|
// A vendored rust tree gets its `vendor/` directory moved into the
|
|
// separate dpkg upstream component `orig-vendor`, regenerable
|
|
// independently of the upstream sources (native packages have no
|
|
// orig at all: vendor/ simply lives in the tree).
|
|
if opts.source_format == SourceFormat::Quilt {
|
|
pb.set_message("Creating orig tarball");
|
|
let vendored_rust =
|
|
template.id() == options::TemplateId::Rust && orig::has_vendored_dir(&target);
|
|
let created = orig::create_orig(
|
|
&target,
|
|
&opts.name,
|
|
&opts.upstream_version_no_epoch(),
|
|
opts.orig
|
|
.as_ref()
|
|
.ok_or("internal error: a quilt scaffold needs an orig-tarball plan")?,
|
|
vendored_rust,
|
|
)?;
|
|
outcome.orig_origin = Some(created.label);
|
|
if vendored_rust {
|
|
pb.set_message("Creating the orig-vendor component");
|
|
orig::create_vendor_component(&target, &opts.name, &opts.upstream_version_no_epoch())?;
|
|
}
|
|
}
|
|
|
|
// 7. Git.
|
|
pb.set_message("Initializing git");
|
|
git::ensure_repository(&target, opts.git)?;
|
|
|
|
// 8. Structural verification.
|
|
pb.set_message("Verifying");
|
|
verify::verify(&target)?;
|
|
|
|
Ok(outcome)
|
|
}
|
|
|
|
/// The success message: what was created and the next steps.
|
|
fn print_success(opts: &NewOptions, outcome: &ScaffoldOutcome) {
|
|
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): `Created .` would be cryptic, so spell the
|
|
// location out; the skeleton/path modes keep the `<dir>` display.
|
|
let display = crate::ui::display_path(&target);
|
|
let location = if display.is_empty() {
|
|
"package in the current directory".to_string()
|
|
} else {
|
|
display.clone()
|
|
};
|
|
log::info!(
|
|
"Created {location} — {} ({}-{}) for {}/{}, template '{}'",
|
|
opts.name,
|
|
opts.upstream_version,
|
|
opts.revision,
|
|
opts.dist,
|
|
opts.series,
|
|
opts.template
|
|
);
|
|
if let Some(orig_origin) = &outcome.orig_origin {
|
|
log::info!("Orig tarball: {orig_origin}");
|
|
}
|
|
log::info!("Next steps:");
|
|
log::info!(" cd {}", if display.is_empty() { "." } else { &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, OrigOrigin, SourceDir, TemplateId};
|
|
use serial_test::serial;
|
|
use tempfile::tempdir;
|
|
|
|
fn opts(template: TemplateId, name: &str, source_dir: SourceDir) -> NewOptions {
|
|
// Mirror the resolve() derivation: skeletons are native by default,
|
|
// existing projects quilt with a working-tree snapshot orig.
|
|
let (source_format, orig) = match source_dir {
|
|
SourceDir::Skeleton => (SourceFormat::Native, None),
|
|
SourceDir::Here | SourceDir::Path(_) => {
|
|
(SourceFormat::Quilt, Some(OrigOrigin::Snapshot))
|
|
}
|
|
};
|
|
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(),
|
|
source_format,
|
|
orig,
|
|
git: false,
|
|
autopkgtest: false,
|
|
pkg_config: false,
|
|
watch: None,
|
|
}
|
|
}
|
|
|
|
/// 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<ScaffoldOutcome, Box<dyn Error>> {
|
|
let previous = std::env::current_dir()?;
|
|
std::env::set_current_dir(dir)?;
|
|
// Hidden draw target: in tests the spinner would redraw from its
|
|
// steady-tick thread straight to the real stderr
|
|
let result = scaffold(
|
|
opts,
|
|
&MultiProgress::with_draw_target(crate::ui::progress_draw_target()),
|
|
);
|
|
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/.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");
|
|
|
|
// A fresh skeleton is 3.0 (native) by default: no local-options and
|
|
// no orig tarball anywhere.
|
|
assert_eq!(
|
|
std::fs::read_to_string(tree.join("debian/source/format")).unwrap(),
|
|
"3.0 (native)\n"
|
|
);
|
|
assert!(!tree.join("debian/source/local-options").exists());
|
|
assert!(!dir.path().join("mytool_0.1.0.orig.tar.xz").exists());
|
|
|
|
// 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/"));
|
|
}
|
|
|
|
/// A skeleton forced to quilt keeps the snapshot behavior: orig tarball
|
|
/// with the skeleton files, `debian/` excluded, local-options present.
|
|
#[test]
|
|
#[serial]
|
|
fn scaffold_skeleton_forced_quilt_snapshots_the_tree() {
|
|
let dir = tempdir().unwrap();
|
|
let mut o = opts(TemplateId::Shell, "mytool", SourceDir::Skeleton);
|
|
o.source_format = SourceFormat::Quilt;
|
|
o.orig = Some(OrigOrigin::Snapshot);
|
|
scaffold_in(dir.path(), o).unwrap();
|
|
|
|
let tree = dir.path().join("mytool");
|
|
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\
|
|
extend-diff-ignore = ^target/\n\
|
|
extend-diff-ignore = ^node_modules/\n\
|
|
extend-diff-ignore = ^\\.venv/\n"
|
|
);
|
|
|
|
// 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.
|
|
// A native skeleton carries no orig tarball: the README simply
|
|
// lives in the tree.
|
|
assert!(!tree.join("debian/install").exists());
|
|
assert!(tree.join("README").exists());
|
|
assert!(!dir.path().join("metapkg_0.1.0.orig.tar.xz").exists());
|
|
|
|
// 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 (the shell template contributes none and the
|
|
// artifact entries are skeleton-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 (quilt only): nothing gets written.
|
|
let dir = tempdir().unwrap();
|
|
std::fs::write(dir.path().join("mytool_0.1.0.orig.tar.xz"), b"old").unwrap();
|
|
let mut o = opts(TemplateId::Shell, "mytool", SourceDir::Skeleton);
|
|
o.source_format = SourceFormat::Quilt;
|
|
o.orig = Some(OrigOrigin::Snapshot);
|
|
let err = scaffold_in(dir.path(), o).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}");
|
|
}
|
|
|
|
/// The skeleton default is `3.0 (native)`: no orig tarball, no
|
|
/// `debian/source/local-options`.
|
|
#[test]
|
|
#[serial]
|
|
fn scaffold_skeleton_defaults_to_native() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_in(
|
|
dir.path(),
|
|
opts(TemplateId::Shell, "nativepkg", SourceDir::Skeleton),
|
|
)
|
|
.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 rust skeleton: the vendoring hook runs over the tree
|
|
/// (native format: everything simply lives in the tree, no orig
|
|
/// tarball). The vendoring step needs host cargo; on a cargo-less host
|
|
/// the scaffold still succeeds with a warning and a `vendoring_failed`
|
|
/// outcome. Keyed against the `RUSTUP_TOOLCHAIN` tests of the rust
|
|
/// template: they mutate the process-global environment the cargo shim
|
|
/// would pick up mid-vendoring.
|
|
#[test]
|
|
#[serial]
|
|
#[serial(RUSTUP_TOOLCHAIN)]
|
|
fn scaffold_rust_skeleton_vendors_into_the_tree() {
|
|
let dir = tempdir().unwrap();
|
|
let outcome = scaffold_in(
|
|
dir.path(),
|
|
opts(TemplateId::Rust, "mytool", SourceDir::Skeleton),
|
|
)
|
|
.unwrap();
|
|
|
|
let has_cargo = crate::new::templates::find_on_path("cargo").is_some();
|
|
assert_eq!(outcome.vendoring_failed, !has_cargo);
|
|
// Native skeleton: no orig tarball at all.
|
|
assert_eq!(outcome.orig_origin, None);
|
|
assert!(!dir.path().join("mytool_0.1.0.orig.tar.xz").exists());
|
|
|
|
let tree = dir.path().join("mytool");
|
|
assert!(tree.join("Cargo.toml").exists());
|
|
assert!(tree.join("src/main.rs").exists());
|
|
// The vendoring step is what creates Cargo.lock for a skeleton.
|
|
assert_eq!(tree.join("Cargo.lock").exists(), has_cargo);
|
|
|
|
// rules: the vendored build overrides, and `--locked` exactly when
|
|
// the vendoring step left a lockfile behind (it appears after the
|
|
// rules were rendered, so the hook patches it in).
|
|
let rules = std::fs::read_to_string(tree.join("debian/rules")).unwrap();
|
|
assert!(rules.contains("%:\n\tdh $@\n"));
|
|
assert!(rules.contains("override_dh_auto_build:\n\tcargo build --release --offline"));
|
|
assert!(rules.contains("override_dh_auto_install:\n\tinstall -Dm755 target/release/mytool debian/mytool/usr/bin/mytool"));
|
|
assert_eq!(rules.contains("--locked"), has_cargo);
|
|
|
|
// control: Architecture any + the cargo/rustc build-deps.
|
|
let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap();
|
|
assert_eq!(control.binaries[0].get("Architecture"), Some("any"));
|
|
assert_eq!(
|
|
control.source.get("Build-Depends"),
|
|
Some("debhelper-compat (= 13),\ncargo:native,\nrustc:native")
|
|
);
|
|
|
|
// The offline config exists in the tree when host cargo vendored
|
|
// the skeleton.
|
|
if has_cargo {
|
|
let config = std::fs::read_to_string(tree.join(".cargo/config.toml")).unwrap();
|
|
assert!(config.contains("[source.crates-io]"), "{config}");
|
|
assert!(config.contains("[net]\noffline = true"), "{config}");
|
|
}
|
|
|
|
// Root .gitignore: the skeleton build-artifact entries plus the
|
|
// template's vendoring entries (contributed in every mode).
|
|
let gitignore = std::fs::read_to_string(tree.join(".gitignore")).unwrap();
|
|
assert!(gitignore.contains("# pkh build artifacts"), "{gitignore}");
|
|
assert!(gitignore.contains("*.deb"), "{gitignore}");
|
|
assert!(gitignore.contains("target/"), "{gitignore}");
|
|
assert!(gitignore.contains("vendor/\n"), "{gitignore}");
|
|
assert!(gitignore.contains(".cargo/config.toml\n"), "{gitignore}");
|
|
}
|
|
|
|
/// The rust template's vendoring entries land in the root `.gitignore`
|
|
/// in every mode: a Here-mode tree gets them appended to its existing
|
|
/// file (custom lines kept, no header comment), while the skeleton-only
|
|
/// build-artifact entries do not appear.
|
|
#[test]
|
|
#[serial]
|
|
fn scaffold_rust_here_merges_vendoring_gitignore_entries() {
|
|
let dir = tempdir().unwrap();
|
|
let tree = dir.path().join("packdir");
|
|
std::fs::create_dir_all(&tree).unwrap();
|
|
std::fs::write(tree.join(".gitignore"), "# my project\n*.log\n").unwrap();
|
|
scaffold_in(&tree, opts(TemplateId::Rust, "mytool", SourceDir::Here)).unwrap();
|
|
|
|
let gitignore = std::fs::read_to_string(tree.join(".gitignore")).unwrap();
|
|
assert!(
|
|
gitignore.starts_with("# my project\n*.log\n"),
|
|
"{gitignore}"
|
|
);
|
|
assert!(gitignore.contains("vendor/\n"), "{gitignore}");
|
|
assert!(gitignore.contains(".cargo/config.toml\n"), "{gitignore}");
|
|
// The build-artifact entries stay skeleton-only.
|
|
assert!(!gitignore.contains("*.deb"), "{gitignore}");
|
|
assert!(!gitignore.contains("# pkh build artifacts"), "{gitignore}");
|
|
}
|
|
|
|
/// End-to-end vendored rust quilt package: the vendoring hook creates
|
|
/// `vendor/`, the main orig excludes it, the dpkg upstream component
|
|
/// `orig-vendor` carries it, and the real source build
|
|
/// (`run_source_build`) lists BOTH tarballs in the `.dsc` and succeeds.
|
|
/// Needs host cargo with a working crates.io sync (skipped gracefully
|
|
/// when either is unavailable) and the local dpkg tools.
|
|
#[test]
|
|
#[serial]
|
|
#[serial(RUSTUP_TOOLCHAIN)]
|
|
fn scaffold_rust_quilt_with_deps_vendors_into_a_component() {
|
|
let dir = tempdir().unwrap();
|
|
let source = dir.path().join("mytool");
|
|
std::fs::create_dir_all(source.join("src")).unwrap();
|
|
// `libc` resolves from the host's cargo registry cache; vendoring
|
|
// it needs one crates.io index sync.
|
|
std::fs::write(
|
|
source.join("Cargo.toml"),
|
|
"[package]\n\
|
|
name = \"mytool\"\n\
|
|
version = \"0.1.0\"\n\
|
|
edition = \"2021\"\n\
|
|
\n\
|
|
[dependencies]\n\
|
|
libc = \"0.2\"\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(source.join("src/main.rs"), "fn main() {}\n").unwrap();
|
|
|
|
let mut o = opts(TemplateId::Rust, "mytool", SourceDir::Path(source.clone()));
|
|
o.source_format = SourceFormat::Quilt;
|
|
o.orig = Some(OrigOrigin::Snapshot);
|
|
let outcome = scaffold_in(dir.path(), o).unwrap();
|
|
|
|
if crate::new::templates::find_on_path("cargo").is_none() || outcome.vendoring_failed {
|
|
// No cargo on this host or the crates.io sync failed: the
|
|
// vendoring guarantees of this test cannot hold.
|
|
log::warn!("cargo/crates.io unavailable; skipping the vendored component checks");
|
|
return;
|
|
}
|
|
assert_eq!(
|
|
outcome.orig_origin,
|
|
Some("working tree snapshot".to_string())
|
|
);
|
|
|
|
// The main orig excludes vendor/ but carries the upstream files.
|
|
let tarball = dir.path().join("mytool_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 == "mytool-0.1.0/Cargo.toml"),
|
|
"{names:?}"
|
|
);
|
|
assert!(!names.iter().any(|n| n.contains("vendor")), "{names:?}");
|
|
|
|
// The component carries vendor/ under a top-level vendor/ dir.
|
|
let component = dir.path().join("mytool_0.1.0.orig-vendor.tar.xz");
|
|
assert!(component.exists());
|
|
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
|
|
std::fs::File::open(&component).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.starts_with("vendor/libc/")),
|
|
"{names:?}"
|
|
);
|
|
|
|
// The real source build: the .dsc references both tarballs.
|
|
let output = crate::build::run_source_build(
|
|
&source,
|
|
&crate::build::SourceBuildOptions::default(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let dsc = std::fs::read_to_string(&output.dsc).unwrap();
|
|
assert!(dsc.contains("mytool_0.1.0.orig.tar.xz"), "{dsc}");
|
|
assert!(dsc.contains("mytool_0.1.0.orig-vendor.tar.xz"), "{dsc}");
|
|
assert!(
|
|
output
|
|
.tarballs
|
|
.iter()
|
|
.any(|t| t.ends_with("mytool_0.1.0.orig.tar.xz"))
|
|
);
|
|
assert!(
|
|
output
|
|
.tarballs
|
|
.iter()
|
|
.any(|t| t.ends_with("mytool_0.1.0.orig-vendor.tar.xz"))
|
|
);
|
|
assert!(!output.signed);
|
|
}
|
|
|
|
/// End-to-end python skeleton: pyproject-based Build-Depends (native
|
|
/// skeleton: the upstream files live in the tree, no orig tarball).
|
|
#[test]
|
|
#[serial]
|
|
fn scaffold_python_skeleton_tree() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_in(
|
|
dir.path(),
|
|
opts(TemplateId::Python, "mytool", SourceDir::Skeleton),
|
|
)
|
|
.unwrap();
|
|
|
|
let tree = dir.path().join("mytool");
|
|
let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap();
|
|
assert_eq!(control.binaries[0].get("Architecture"), Some("all"));
|
|
assert_eq!(
|
|
control.source.get("Build-Depends"),
|
|
Some(
|
|
"debhelper-compat (= 13),\ndh-python,\npython3-all,\n\
|
|
pybuild-plugin-pyproject,\npython3-setuptools"
|
|
)
|
|
);
|
|
let rules = std::fs::read_to_string(tree.join("debian/rules")).unwrap();
|
|
assert!(rules.contains("%:\n\tdh $@ --with python3 --buildsystem=pybuild\n"));
|
|
|
|
assert!(tree.join("pyproject.toml").exists());
|
|
assert!(tree.join("mytool/__init__.py").exists());
|
|
assert!(!dir.path().join("mytool_0.1.0.orig.tar.xz").exists());
|
|
}
|
|
|
|
/// End-to-end: a quilt tree (Here mode over an existing source) 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();
|
|
let tree = dir.path().join("mytool");
|
|
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, "mytool", SourceDir::Here)).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);
|
|
}
|
|
|
|
/// End-to-end native: a self-authored skeleton (3.0 (native), no orig
|
|
/// tarball) builds into a .dsc without any tarball at all.
|
|
#[test]
|
|
#[serial]
|
|
fn scaffold_native_then_source_build_needs_no_tarball() {
|
|
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.changes.exists(), "{:?} missing", output.changes);
|
|
// 3.0 (native): one self-contained source tarball (debian/ inside),
|
|
// but no ORIG tarball.
|
|
assert_eq!(output.tarballs.len(), 1, "{:?}", output.tarballs);
|
|
assert!(
|
|
!output.tarballs[0]
|
|
.file_name()
|
|
.is_some_and(|name| name.to_string_lossy().contains("orig")),
|
|
"{:?}",
|
|
output.tarballs
|
|
);
|
|
}
|
|
}
|