//! `pkh new`: interactive-first package scaffolding. //! //! 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> { 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> { // 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 = debian::files(opts, template); if skeleton { files.extend(template.skeleton(opts)); } files.extend(template.debian(opts)); let paths: Vec = 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(); 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 `` display. let display = crate::report::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> { 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 = 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 = 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 = 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 = 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(), &crate::report::Quiet, ) .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(), &crate::report::Quiet, ) .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(), &crate::report::Quiet, ) .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 ); } }