//! `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 //! template post-write hook (e.g. `cargo vendor`), orig tarball creation, //! 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; pub mod options; 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; use templates::OutputFile; /// 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` in skeleton /// mode, appending to an existing one), /// 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. pub fn scaffold(opts: NewOptions, multi: &MultiProgress) -> Result<(), Box> { 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 result.is_ok() { print_success(&opts); } 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<(), Box> { // 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.native && 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: skeleton mode only, never overwriting an existing // file (append the missing entries instead). if skeleton && let Some(contents) = debian::merge_root_gitignore( std::fs::read_to_string(target.join(".gitignore")) .ok() .as_deref(), ) { 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. pb.set_message("Running template hooks"); template.post_write(opts, &target)?; // 6. Orig tarball (quilt only). if !opts.native { pb.set_message("Creating orig tarball"); debian::create_orig_tarball(&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(()) } /// The success message: what was created and the next steps. fn print_success(opts: &NewOptions) { 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): show it as `.`. let display = match crate::ui::display_path(&target) { display if display.is_empty() => ".".to_string(), display => display, }; log::info!( "Created {display} — {} ({}-{}) for {}/{}, template '{}'", opts.name, opts.upstream_version, opts.revision, opts.dist, opts.series, opts.template ); log::info!("Next steps:"); log::info!(" cd {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, SourceDir, TemplateId}; use serial_test::serial; use tempfile::tempdir; fn opts(template: TemplateId, name: &str, source_dir: SourceDir) -> NewOptions { 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(), native: false, 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<(), Box> { let previous = std::env::current_dir()?; std::env::set_current_dir(dir)?; let result = scaffold(opts, &MultiProgress::new()); 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/source/local-options", "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"); // source/format + local-options. 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" ); // 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/")); // 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. assert!(!tree.join("debian/install").exists()); assert!(tree.join("README").exists()); // The tarball excludes debian/ but carries the README. let tarball = dir.path().join("metapkg_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 == "metapkg-0.1.0/README"), "{names:?}" ); // 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 (skeleton mode 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: nothing gets written. let dir = tempdir().unwrap(); std::fs::write(dir.path().join("mytool_0.1.0.orig.tar.xz"), b"old").unwrap(); let err = scaffold_in( dir.path(), opts(TemplateId::Shell, "mytool", SourceDir::Skeleton), ) .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}"); } #[test] #[serial] fn scaffold_native_skips_tarball_and_local_options() { let dir = tempdir().unwrap(); let mut o = opts(TemplateId::Shell, "nativepkg", SourceDir::Skeleton); o.native = true; scaffold_in(dir.path(), o).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 before the orig /// tarball is created, so `.cargo/` (and `vendor/` when dependencies /// exist) travel inside it. The vendoring step needs host cargo; on a /// cargo-less host the scaffold still succeeds with a warning. #[test] #[serial] fn scaffold_rust_skeleton_vendors_before_tarball() { let dir = tempdir().unwrap(); scaffold_in( dir.path(), opts(TemplateId::Rust, "mytool", SourceDir::Skeleton), ) .unwrap(); let tree = dir.path().join("mytool"); assert!(tree.join("Cargo.toml").exists()); assert!(tree.join("src/main.rs").exists()); // rules: the vendored build overrides, no --locked on a fresh // skeleton without Cargo.lock. 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\n")); assert!(rules.contains("override_dh_auto_install:\n\tinstall -Dm755 target/release/mytool debian/mytool/usr/bin/mytool")); assert!(!rules.contains("--locked")); // 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 when host cargo vendored the skeleton, // and both it and the skeleton land inside the orig tarball. let has_cargo = crate::new::templates::find_on_path("cargo").is_some(); 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}"); } 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 == "mytool-0.1.0/src/main.rs"), "{names:?}" ); if has_cargo { assert!( names.iter().any(|n| n == "mytool-0.1.0/.cargo/config.toml"), "{names:?}" ); } assert!(!names.iter().any(|n| n.contains("debian")), "{names:?}"); } /// End-to-end python skeleton: pyproject-based Build-Depends and the /// module skeleton inside the 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")); 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/pyproject.toml"), "{names:?}" ); assert!( names.iter().any(|n| n == "mytool-0.1.0/mytool/__init__.py"), "{names:?}" ); } /// End-to-end: the scaffolded shell tree 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(); 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.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); } }