From d044f757e9cba592e3e1c3983b3cc4e0546a9726 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Wed, 16 Sep 2026 12:14:09 +0200 Subject: [PATCH] new: scaffold new Debian source packages (non-interactive core) --- src/changelog.rs | 8 +- src/lib.rs | 2 + src/main.rs | 149 ++++++ src/new/debian.rs | 730 ++++++++++++++++++++++++++++ src/new/detect.rs | 206 ++++++++ src/new/git.rs | 64 +++ src/new/mod.rs | 589 +++++++++++++++++++++++ src/new/options.rs | 943 +++++++++++++++++++++++++++++++++++++ src/new/templates/empty.rs | 84 ++++ src/new/templates/mod.rs | 164 +++++++ src/new/templates/shell.rs | 91 ++++ src/new/verify.rs | 214 +++++++++ 12 files changed, 3243 insertions(+), 1 deletion(-) create mode 100644 src/new/debian.rs create mode 100644 src/new/detect.rs create mode 100644 src/new/git.rs create mode 100644 src/new/mod.rs create mode 100644 src/new/options.rs create mode 100644 src/new/templates/empty.rs create mode 100644 src/new/templates/mod.rs create mode 100644 src/new/templates/shell.rs create mode 100644 src/new/verify.rs diff --git a/src/changelog.rs b/src/changelog.rs index 6edc364..64dd429 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -259,7 +259,13 @@ fn prepend_to_file(path: &Path, content: &str) -> Result<(), Box Result<(String, String), Box> { +/// Discover the maintainer identity for changelog entries and package +/// scaffolding: `$DEBFULLNAME`/`$DEBEMAIL` when both are set, else the git +/// configuration (`user.name`/`user.email`). +/// +/// Returns `(name, email)`, with a pointed error telling the user how to +/// configure the missing piece. +pub fn get_maintainer_info() -> Result<(String, String), Box> { // From environment variables if let (Ok(name), Ok(email)) = (std::env::var("DEBFULLNAME"), std::env::var("DEBEMAIL")) { return Ok((name, email)); diff --git a/src/lib.rs b/src/lib.rs index d27cf71..8c94ae6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,8 @@ pub mod deb; pub mod debian; /// Obtain general information about distribution, series, etc pub mod distro_info; +/// Scaffold a new Debian source package (`pkh new`) +pub mod new; /// Obtain information about one or multiple packages pub mod package_info; /// Prune residual pkh build artifacts and caches diff --git a/src/main.rs b/src/main.rs index 5bee718..39d1289 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,105 @@ fn main() { let matches = command!() .subcommand_required(true) .disable_version_flag(true) + .subcommand( + Command::new("new") + .about("Scaffold a new Debian source package (buildable right away)") + .arg(arg!([name] "Package name: creates .// with a fresh project skeleton. Without it (or with --source), the given/current directory is packaged")) + // NOTE: hyphenated long names are defined via the builder API + // because clap's `arg!` macro mis-tokenizes them (see the + // prune subcommand note below). + .arg( + clap::Arg::new("lang") + .long("lang") + .value_name("LANG") + .help("Language/build system: rust, python, meson, cmake, autotools, go, shell, makefile or empty"), + ) + .arg( + clap::Arg::new("source") + .long("source") + .value_name("PATH") + .conflicts_with("name") + .help("Package the sources in PATH instead of creating .//"), + ) + .arg( + clap::Arg::new("upstream_version") + .long("upstream-version") + .value_name("VERSION") + .help("Upstream version (default: 0.1.0)"), + ) + .arg( + clap::Arg::new("revision") + .long("revision") + .value_name("N") + .value_parser(clap::value_parser!(u32)) + .help("Debian revision (default: 1)"), + ) + .arg( + clap::Arg::new("description") + .long("description") + .value_name("DESC") + .help("One-line package description"), + ) + .arg( + clap::Arg::new("homepage") + .long("homepage") + .value_name("URL") + .help("Upstream homepage (http:// or https://)"), + ) + .arg( + clap::Arg::new("license") + .long("license") + .value_name("SPDX") + .help("Upstream license (SPDX identifier, e.g. MIT, GPL-3.0+)"), + ) + .arg( + clap::Arg::new("command") + .long("command") + .value_name("CMD") + .help("Installed command name (default: the package name)"), + ) + .arg( + clap::Arg::new("maintainer") + .long("maintainer") + .value_name("NAME ") + .help("Maintainer (default: DEBFULLNAME/DEBEMAIL, then git config)"), + ) + .arg( + clap::Arg::new("depends") + .long("depends") + .value_name("LIST") + .action(clap::ArgAction::Append) + .long_help("Runtime Depends of the metapackage flavor ('empty' template), as a comma-separated list (e.g. \"hello, hello-data (>= 1.0)\"). Can be specified multiple times.") + .help("Metapackage Depends list, comma-separated ('empty' template only)"), + ) + .arg( + clap::Arg::new("dist") + .long("dist") + .value_name("DIST") + .help("Target distribution: debian or ubuntu (default: current vendor)"), + ) + .arg( + clap::Arg::new("series") + .long("series") + .value_name("SERIES") + .help("Target series (default: the development series of --dist)"), + ) + .arg(arg!(--release "Write the --series into debian/changelog instead of UNRELEASED").required(false)) + .arg(arg!(--native "Use the 3.0 (native) source format (no orig tarball)").required(false)) + .arg( + clap::Arg::new("no_git") + .long("no-git") + .action(clap::ArgAction::SetTrue) + .help("Do not initialize a git repository (.gitignore files are written anyway)"), + ) + .arg( + clap::Arg::new("no_verify") + .long("no-verify") + .action(clap::ArgAction::SetTrue) + .help("Skip the post-scaffold build verification (the structural self-checks always run)"), + ) + .arg(arg!(--defaults "Take the default answer for every question left unanswered (the package name is still required)").required(false)), + ) .subcommand( Command::new("pull") .about("Pull a source package from the archive or git") @@ -134,6 +233,56 @@ fn main() { .get_matches(); match matches.subcommand() { + Some(("new", sub_matches)) => { + // Without the interactive wizard (follow-up work), missing + // required answers produce one error listing all of them; + // --defaults fills everything else from the defaults. + let depends: Vec = sub_matches + .get_many::("depends") + .map(|values| values.cloned().collect()) + .unwrap_or_default(); + let cli = pkh::new::options::NewCli { + name: sub_matches.get_one::("name").cloned(), + lang: sub_matches.get_one::("lang").cloned(), + source: sub_matches + .get_one::("source") + .map(std::path::PathBuf::from), + upstream_version: sub_matches.get_one::("upstream_version").cloned(), + revision: sub_matches.get_one::("revision").copied(), + description: sub_matches.get_one::("description").cloned(), + homepage: sub_matches.get_one::("homepage").cloned(), + license: sub_matches.get_one::("license").cloned(), + command: sub_matches.get_one::("command").cloned(), + maintainer: sub_matches.get_one::("maintainer").cloned(), + depends, + dist: sub_matches.get_one::("dist").cloned(), + series: sub_matches.get_one::("series").cloned(), + release: sub_matches + .get_one::("release") + .copied() + .unwrap_or(false), + native: sub_matches + .get_one::("native") + .copied() + .unwrap_or(false), + git: !sub_matches + .get_one::("no_git") + .copied() + .unwrap_or(false), + defaults: sub_matches + .get_one::("defaults") + .copied() + .unwrap_or(false), + }; + + if let Err(e) = rt.block_on(async { + let opts = pkh::new::options::resolve(cli).await?; + pkh::new::scaffold(opts, &multi) + }) { + error!("{}", e); + std::process::exit(1); + } + } Some(("pull", sub_matches)) => { let package = sub_matches.get_one::("package").expect("required"); let series = sub_matches.get_one::("series").map(|s| s.as_str()); diff --git a/src/new/debian.rs b/src/new/debian.rs new file mode 100644 index 0000000..6a89591 --- /dev/null +++ b/src/new/debian.rs @@ -0,0 +1,730 @@ +//! Generators for the common `debian/` files of a scaffolded package, plus +//! the orig tarball creation. +//! +//! Everything here renders in memory as [`OutputFile`]s; the caller writes +//! them all-or-nothing after checking for collisions (see +//! [`super::scaffold`]). + +use std::collections::HashSet; +use std::path::Path; + +use chrono::Datelike; +use tar::Builder; +use xz2::write::XzEncoder; + +use super::options::NewOptions; +use super::templates::{OutputFile, Template}; + +/// `3.0 (quilt)` source format, the pkh new default. +pub const SOURCE_FORMAT_QUILT: &str = "3.0 (quilt)"; +/// `3.0 (native)` source format, selected by `--native`. +pub const SOURCE_FORMAT_NATIVE: &str = "3.0 (native)"; + +/// The three source formats pkh knows how to build. +pub const KNOWN_SOURCE_FORMATS: [&str; 3] = [SOURCE_FORMAT_QUILT, SOURCE_FORMAT_NATIVE, "1.0"]; + +/// Directory and file names excluded from the orig tarball, at any depth of +/// the tree. +const ORIG_EXCLUDE: &[&str] = &[ + ".git", + "debian", + "target", + "node_modules", + "__pycache__", + ".venv", +]; + +/// Path of the orig tarball for `name`/`upstream_version` next to `tree`. +pub fn orig_tarball_path( + tree: &Path, + name: &str, + upstream_version: &str, +) -> Option { + tree.parent() + .map(|parent| parent.join(format!("{name}_{upstream_version}.orig.tar.xz"))) +} + +/// Render every common `debian/` file of the package. +pub fn files(opts: &NewOptions, template: &dyn Template) -> Vec { + let mut files = vec![ + source_format(opts), + changelog(opts), + control(opts, template), + rules(template), + copyright(opts), + debian_gitignore(opts), + ]; + if !opts.native { + files.push(local_options()); + } + files +} + +/// `debian/source/format`: `3.0 (quilt)` by default, `3.0 (native)` with +/// `--native`. +fn source_format(opts: &NewOptions) -> OutputFile { + OutputFile::new( + "debian/source/format", + format!( + "{}\n", + if opts.native { + SOURCE_FORMAT_NATIVE + } else { + SOURCE_FORMAT_QUILT + } + ), + ) +} + +/// `debian/source/local-options` with `single-debian-patch`, so later +/// upstream-tree edits stay representable as one `debian/patches/debian-changes-*` +/// patch instead of failing the build (quilt only). +fn local_options() -> OutputFile { + OutputFile::new("debian/source/local-options", "single-debian-patch\n") +} + +/// `debian/changelog`: the single initial entry, distribution UNRELEASED by +/// default (the dh_make convention: a fresh package is by definition not +/// ready for upload, and pkh skips signing for UNRELEASED), or the target +/// series with `--release`. +fn changelog(opts: &NewOptions) -> OutputFile { + let distribution = if opts.release { + opts.series.as_str() + } else { + crate::distro_info::UNRELEASED + }; + let date = chrono::Local::now().format("%a, %d %b %Y %H:%M:%S %z"); + OutputFile::new( + "debian/changelog", + format!( + "{name} ({version}) {distribution}; urgency=medium\n\ + \n\ + \x20 * Initial release.\n\ + \n\ + \x20-- {maintainer_name} <{maintainer_email}> {date}\n", + name = opts.name, + version = opts.full_version(), + distribution = distribution, + maintainer_name = opts.maintainer.0, + maintainer_email = opts.maintainer.1, + date = date, + ), + ) +} + +/// Render a field whose values continue one per line (RFC822 continuation, +/// one leading space, commas between values, first value on the field line): +/// +/// ```text +/// Build-Depends: debhelper-compat (= 13), +/// python3-all +/// ``` +fn render_field(name: &str, values: &[String]) -> String { + let last = values.len() - 1; + let mut out = format!("{}: {}", name, values[0]); + if last > 0 { + out.push(','); + } + out.push('\n'); + for (i, value) in values.iter().enumerate().skip(1) { + out.push_str(&format!(" {value}")); + if i != last { + out.push(','); + } + out.push('\n'); + } + out +} + +/// Render a free-text field body (long description, license paragraphs): +/// every line as a continuation, blank lines as ` .` (the deb822 encoding). +fn render_continuation_text(text: &str) -> String { + let mut out = String::new(); + for line in text.lines() { + if line.trim().is_empty() { + out.push_str(" .\n"); + } else { + out.push_str(&format!(" {line}\n")); + } + } + out +} + +/// `debian/control`: one source stanza plus one binary stanza. +/// +/// The binary package name is the source package name, the architecture +/// comes from the template (`all` for shell/empty), and a non-empty +/// `opts.depends` (the empty/metapackage flavor) lands in the binary +/// stanza's `Depends` field. +fn control(opts: &NewOptions, template: &dyn Template) -> OutputFile { + let mut control = String::new(); + + // Source stanza. + control.push_str(&format!("Source: {}\n", opts.name)); + control.push_str("Section: utils\n"); + control.push_str("Priority: optional\n"); + control.push_str(&format!( + "Maintainer: {} <{}>\n", + opts.maintainer.0, opts.maintainer.1 + )); + control.push_str("Rules-Requires-Root: no\n"); + + let mut build_depends = vec!["debhelper-compat (= 13)".to_string()]; + build_depends.extend(template.build_depends(opts)); + control.push_str(&render_field("Build-Depends", &build_depends)); + + if let Some(homepage) = &opts.homepage { + control.push_str(&format!("Homepage: {homepage}\n")); + } + + control.push('\n'); + + // Binary stanza. + control.push_str(&format!("Package: {}\n", opts.name)); + control.push_str(&format!("Architecture: {}\n", template.architecture())); + if !opts.depends.is_empty() { + control.push_str(&render_field("Depends", &opts.depends)); + } + control.push_str(&format!("Description: {}\n", opts.summary)); + control.push_str(&render_continuation_text(&opts.long_description)); + + OutputFile::new("debian/control", control) +} + +/// `debian/rules`: the minimal `dh $@` makefile (plus the template's extra +/// overrides, when any), written with the executable bit. +fn rules(template: &dyn Template) -> OutputFile { + let mut contents = String::from("#!/usr/bin/make -f\n%:\n\tdh $@\n"); + let extra = template.rules_extra(); + if !extra.is_empty() { + contents.push('\n'); + contents.push_str(&extra); + if !contents.ends_with('\n') { + contents.push('\n'); + } + } + OutputFile::executable("debian/rules", contents) +} + +/// Short license-reference paragraph embedded in `debian/copyright`. +fn license_reference_paragraph(license: &super::options::License) -> String { + use super::options::License; + match license { + License::Custom(s) if s.eq_ignore_ascii_case("unknown") => { + "The licensing terms of this package are not known yet. \ + Replace this paragraph with a proper license reference." + .to_string() + } + License::Custom(s) => format!( + "The package is distributed under the terms of the '{s}' license. \ + Replace this paragraph with the full license reference." + ), + known => format!( + "The package is distributed under the terms of the {} license. \ + The full license text is available at <{}>.", + known.spdx(), + known.spdx_url() + ), + } +} + +/// `debian/copyright` in the DEP-5 machine-readable format: header, the +/// `Files: *` stanza covering the current year, and a standalone license +/// stanza with a short reference paragraph. +fn copyright(opts: &NewOptions) -> OutputFile { + let year = chrono::Local::now().year(); + let mut out = String::new(); + + out.push_str("Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n"); + out.push_str(&format!("Upstream-Name: {}\n", opts.name)); + if let Some(homepage) = &opts.homepage { + out.push_str(&format!("Source: {homepage}\n")); + } + out.push('\n'); + + out.push_str("Files: *\n"); + out.push_str(&format!( + "Copyright: {} {} <{}>\n", + year, opts.maintainer.0, opts.maintainer.1 + )); + out.push_str(&format!("License: {}\n", opts.license.spdx())); + out.push_str(&render_continuation_text(&license_reference_paragraph( + &opts.license, + ))); + out.push('\n'); + + out.push_str(&format!("License: {}\n", opts.license.spdx())); + out.push_str(&render_continuation_text(&license_reference_paragraph( + &opts.license, + ))); + + OutputFile::new("debian/copyright", out) +} + +/// `debian/.gitignore`: the debhelper build artifacts. +fn debian_gitignore(opts: &NewOptions) -> OutputFile { + OutputFile::new( + "debian/.gitignore", + format!( + "debian/files\n\ + debian/.debhelper/\n\ + debian/*.log\n\ + debian/{}/\n\ + debian/debhelper-build-stamp\n\ + debian/*.substvars\n", + opts.name + ), + ) +} + +/// Entries of the root `.gitignore` written in skeleton mode (build +/// artifacts, next to the tree). +pub const ROOT_GITIGNORE_ENTRIES: [&str; 6] = [ + "*.deb", + "*.dsc", + "*.changes", + "*.buildinfo", + "*.tar.xz", + "target/", +]; + +/// Merge the root `.gitignore` entries into `existing` (the current file +/// contents, when there is one): missing entries are appended, an existing +/// file is never overwritten just to duplicate entries. Returns the new +/// contents, or `None` when nothing has to be written. +pub fn merge_root_gitignore(existing: Option<&str>) -> Option { + let have: HashSet<&str> = existing + .map(|content| content.lines().map(str::trim).collect()) + .unwrap_or_default(); + let missing: Vec<&str> = ROOT_GITIGNORE_ENTRIES + .iter() + .copied() + .filter(|entry| !have.contains(entry)) + .collect(); + if missing.is_empty() { + return None; + } + + let mut out = existing.unwrap_or("").to_string(); + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + // Section comment only for a fresh file; appending to a user file adds + // bare entries. + if existing.is_none() { + out.push_str("# pkh build artifacts\n"); + } + for entry in missing { + out.push_str(entry); + out.push('\n'); + } + Some(out) +} + +/// Create `../_.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> { + let tarball_path = orig_tarball_path(tree, name, upstream_version).ok_or_else(|| { + format!( + "cannot determine the parent directory of '{}'", + tree.display() + ) + })?; + if tarball_path.exists() { + return Err(format!( + "'{}' already exists: pkh new refuses to overwrite it. \ + Remove it first, or pass --native to skip the orig tarball.", + tarball_path.display() + ) + .into()); + } + + let file = std::fs::File::create(&tarball_path)?; + let encoder = XzEncoder::new(file, 6); + let mut builder = Builder::new(encoder); + + // Deterministic-ish ordering: sort entries by name at every level. + let prefix = format!("{name}-{upstream_version}"); + // The single top-level directory dpkg-source expects. + builder.append_dir(&prefix, tree)?; + append_tree(&mut builder, tree, &prefix, 0)?; + + builder + .finish() + .map_err(|e| format!("failed to write '{}': {}", tarball_path.display(), e))?; + + log::info!( + "Created orig tarball {}", + crate::ui::display_path(&tarball_path) + ); + Ok(tarball_path) +} + +/// Recursively append `dir` to the archive under `archive_path`, skipping +/// the [`ORIG_EXCLUDE`] names and non-regular files. +fn append_tree( + builder: &mut Builder>, + dir: &Path, + archive_path: &str, + depth: usize, +) -> Result<(), Box> { + let mut entries: Vec = std::fs::read_dir(dir)?.collect::>()?; + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + let path = entry.path(); + let file_name = entry.file_name(); + let name = file_name.to_string_lossy().into_owned(); + + if depth == 0 && name == "debian" { + continue; + } + if ORIG_EXCLUDE.contains(&name.as_str()) { + continue; + } + + let entry_archive_path = format!("{archive_path}/{name}"); + let metadata = std::fs::metadata(&path) + .map_err(|e| format!("cannot stat '{}': {}", path.display(), e))?; + if metadata.is_dir() { + builder.append_dir(&entry_archive_path, &path)?; + append_tree(builder, &path, &entry_archive_path, depth + 1)?; + } else if metadata.is_file() { + // The mode (including the exec bit) travels through the header. + let mut header = tar::Header::new_gnu(); + header.set_metadata(&metadata); + header.set_size(metadata.len()); + let file = std::fs::File::open(&path) + .map_err(|e| format!("cannot read '{}': {}", path.display(), e))?; + builder + .append_data(&mut header, &entry_archive_path, file) + .map_err(|e| format!("cannot add '{}' to the tarball: {}", path.display(), e))?; + } else { + // Sockets, fifos, devices have no business in an orig tarball. + log::warn!( + "Skipping non-regular file '{}' while creating the orig tarball", + path.display() + ); + } + } + Ok(()) +} + +/// Write an in-memory file list to `tree`, creating parent directories and +/// applying the executable bit. Callers must have checked collisions first. +pub(crate) fn write_files( + tree: &Path, + files: &[OutputFile], +) -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + for file in files { + let path = tree.join(&file.path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, &file.contents)?; + if file.executable { + let mut permissions = std::fs::metadata(&path)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions)?; + } + log::debug!("Wrote {}", path.display()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::new::options::{License, SourceDir, TemplateId}; + + fn opts() -> NewOptions { + NewOptions { + name: "mytool".into(), + template: TemplateId::Shell, + source_dir: SourceDir::Skeleton, + upstream_version: "0.1.0".into(), + revision: 1, + summary: "A tool that does one thing well".into(), + long_description: "A tool that does one thing well".into(), + homepage: Some("https://example.com/mytool".into()), + license: License::Mit, + command: "mytool".into(), + maintainer: ("Jane Doe".into(), "jane@example.com".into()), + dist: "ubuntu".into(), + series: "resolute".into(), + release: false, + depends: Vec::new(), + native: false, + git: true, + } + } + + #[test] + fn source_format_and_local_options() { + let o = opts(); + let files = super::files(&o, crate::new::templates::get(TemplateId::Shell).unwrap()); + let find = |path: &str| { + files + .iter() + .find(|f| f.path == path) + .unwrap_or_else(|| panic!("{path} missing")) + }; + + assert_eq!(find("debian/source/format").contents, "3.0 (quilt)\n"); + assert_eq!( + find("debian/source/local-options").contents, + "single-debian-patch\n" + ); + + let native = NewOptions { + native: true, + ..opts() + }; + let files = super::files( + &native, + crate::new::templates::get(TemplateId::Shell).unwrap(), + ); + assert!( + files + .iter() + .all(|f| f.path != "debian/source/local-options") + ); + assert_eq!( + files + .iter() + .find(|f| f.path == "debian/source/format") + .unwrap() + .contents, + "3.0 (native)\n" + ); + } + + #[test] + fn changelog_rendering_and_parse() { + let o = opts(); + let changelog = super::changelog(&o); + assert_eq!(changelog.path, "debian/changelog"); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("changelog"); + std::fs::write(&path, &changelog.contents).unwrap(); + let (source, version, distribution) = + crate::changelog::parse_changelog_header(&path).unwrap(); + assert_eq!(source, "mytool"); + assert_eq!(version, "0.1.0-1"); + assert_eq!(distribution, "UNRELEASED"); + // dpkg-style zero-padded RFC2822 date in the trailer. + assert!( + changelog + .contents + .contains(" -- Jane Doe ") + ); + let date_line = changelog + .contents + .lines() + .find(|l| l.starts_with(" -- ")) + .unwrap(); + let date = date_line.rsplit_once(" ").unwrap().1; + // `%d` is zero-padded: positions 5-6 must be the two-digit day + // (e.g. "Tue, 05 Sep 2026 ..."). + assert!(date[5..7].bytes().all(|b| b.is_ascii_digit())); + + // --release writes the target series. + let released = NewOptions { + release: true, + ..opts() + }; + std::fs::write(&path, super::changelog(&released).contents).unwrap(); + let (_, _, distribution) = crate::changelog::parse_changelog_header(&path).unwrap(); + assert_eq!(distribution, "resolute"); + } + + #[test] + fn control_rendering_and_parse() { + let o = opts(); + let control = super::control(&o, crate::new::templates::get(TemplateId::Shell).unwrap()); + + // RFC822 continuation: first dep on the field line, the rest indented. + assert!( + control + .contents + .contains("Build-Depends: debhelper-compat (= 13)\n") + ); + + let parsed = crate::debian::ControlInfo::parse_content(&control.contents).unwrap(); + assert_eq!(parsed.source_name(), "mytool"); + assert_eq!(parsed.source.get("Section"), Some("utils")); + assert_eq!(parsed.source.get("Priority"), Some("optional")); + assert_eq!(parsed.source.get("Rules-Requires-Root"), Some("no")); + assert_eq!( + parsed.source.get("Homepage"), + Some("https://example.com/mytool") + ); + assert_eq!(parsed.binaries.len(), 1); + assert_eq!(parsed.binaries[0].get("Package"), Some("mytool")); + assert_eq!(parsed.binaries[0].get("Architecture"), Some("all")); + assert_eq!( + parsed.binaries[0].get("Description"), + Some("A tool that does one thing well\nA tool that does one thing well") + ); + + // Without homepage both the control Homepage field and the DEP-5 + // Source field are absent (the stanza's leading `Source:` line is + // still there of course). + let o = NewOptions { + homepage: None, + ..opts() + }; + let control = super::control(&o, crate::new::templates::get(TemplateId::Shell).unwrap()); + assert!(!control.contents.contains("Homepage:")); + let parsed = crate::debian::ControlInfo::parse_content(&control.contents).unwrap(); + assert!(parsed.source.get("Homepage").is_none()); + } + + #[test] + fn rules_is_executable_minimal_makefile() { + let rules = super::rules(crate::new::templates::get(TemplateId::Shell).unwrap()); + assert!(rules.executable); + assert_eq!(rules.contents, "#!/usr/bin/make -f\n%:\n\tdh $@\n"); + } + + #[test] + fn copyright_is_dep5() { + let c = super::copyright(&opts()); + assert!(c.contents.starts_with( + "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n" + )); + assert!(c.contents.contains("Upstream-Name: mytool\n")); + assert!(c.contents.contains("Source: https://example.com/mytool\n")); + assert!(c.contents.contains("Files: *\n")); + assert!(c.contents.contains("License: MIT\n")); + assert!(c.contents.contains(&format!( + "Copyright: {} Jane Doe \n", + chrono::Local::now().year() + ))); + assert!(c.contents.contains("https://spdx.org/licenses/MIT.html")); + + // Unknown license: honest reference paragraph, still valid deb822. + let o = NewOptions { + license: License::Custom("unknown".into()), + ..opts() + }; + let c = super::copyright(&o); + assert!(c.contents.contains("not known yet")); + assert!(crate::debian::parse_paragraphs(&c.contents).len() >= 3); + } + + #[test] + fn debian_gitignore_contents() { + let g = super::debian_gitignore(&opts()); + assert_eq!( + g.contents, + "debian/files\ndebian/.debhelper/\ndebian/*.log\ndebian/mytool/\n\ + debian/debhelper-build-stamp\ndebian/*.substvars\n" + ); + } + + #[test] + fn root_gitignore_merge() { + // Fresh file: header + all entries. + let fresh = merge_root_gitignore(None).unwrap(); + assert!(fresh.starts_with("# pkh build artifacts\n")); + for entry in ROOT_GITIGNORE_ENTRIES { + assert!(fresh.contains(entry), "{entry} missing"); + } + + // Existing file: only the missing entries are appended, nothing lost. + let existing = "*.deb\nnode_modules/\n"; + let merged = merge_root_gitignore(Some(existing)).unwrap(); + assert!(merged.starts_with(existing)); + assert!(merged.contains("*.dsc\n")); + assert!(!merged.contains("*.deb\n*.deb")); + + // Everything already there: nothing to write. + let full: String = ROOT_GITIGNORE_ENTRIES + .iter() + .map(|e| format!("{e}\n")) + .collect(); + assert!(merge_root_gitignore(Some(&full)).is_none()); + } + + #[test] + fn orig_tarball_layout() { + let dir = tempfile::tempdir().unwrap(); + let tree = dir.path().join("mytool"); + std::fs::create_dir_all(tree.join("debian")).unwrap(); + std::fs::create_dir_all(tree.join("target")).unwrap(); + std::fs::create_dir_all(tree.join("src/nested")).unwrap(); + std::fs::write(tree.join("debian/control"), "control").unwrap(); + std::fs::write(tree.join("target/artifact"), "junk").unwrap(); + std::fs::write(tree.join("src/nested/code.txt"), "code").unwrap(); + + let tarball = create_orig_tarball(&tree, "mytool", "0.1.0").unwrap(); + assert_eq!(tarball, dir.path().join("mytool_0.1.0.orig.tar.xz")); + assert!(tarball.exists()); + + let file = std::fs::File::open(&tarball).unwrap(); + let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(file)); + let mut names: Vec = archive + .entries() + .unwrap() + .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned()) + .collect(); + + // The tree prefix and the nested file are there... + assert!( + names + .iter() + .any(|n| n.trim_end_matches('/') == "mytool-0.1.0") + ); + assert!( + names + .iter() + .any(|n| n == "mytool-0.1.0/src/nested/code.txt") + ); + // ...but debian/, target/ and other excluded names are not. + assert!(!names.iter().any(|n| n.contains("debian"))); + assert!(!names.iter().any(|n| n.contains("target"))); + names.sort(); + } + + #[test] + fn orig_tarball_refuses_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let tree = dir.path().join("mytool"); + std::fs::create_dir_all(&tree).unwrap(); + std::fs::write(dir.path().join("mytool_0.1.0.orig.tar.xz"), b"existing").unwrap(); + + let err = create_orig_tarball(&tree, "mytool", "0.1.0").unwrap_err(); + assert!(err.to_string().contains("already exists")); + } + + #[test] + fn write_files_sets_exec_bit_and_parents() { + let dir = tempfile::tempdir().unwrap(); + let files = vec![ + OutputFile::new("a/b/c.txt", "deep"), + OutputFile::executable("debian/rules", "#!/usr/bin/make -f\n"), + ]; + write_files(dir.path(), &files).unwrap(); + + assert_eq!( + std::fs::read_to_string(dir.path().join("a/b/c.txt")).unwrap(), + "deep" + ); + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.path().join("debian/rules")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o755); + } +} diff --git a/src/new/detect.rs b/src/new/detect.rs new file mode 100644 index 0000000..49b5727 --- /dev/null +++ b/src/new/detect.rs @@ -0,0 +1,206 @@ +//! Project detection for `pkh new`: which template matches an existing +//! source directory. +//! +//! The rule set is deliberately simple and table-driven (highest precedence +//! first): +//! +//! 1. well-known build-system marker files at the top level of the +//! directory (`Cargo.toml`, `pyproject.toml`/`setup.py`/`setup.cfg`, +//! `meson.build`, `CMakeLists.txt`, `configure.ac`, `go.mod`, +//! `Makefile`) — more than one distinct template matching is +//! [`Detection::Ambiguous`], +//! 2. otherwise, exactly one top-level script (a `*.sh` file, or a file +//! whose first line is a `#!` shebang) → [`TemplateId::Shell`], +//! several scripts or none → nothing, +//! 3. otherwise [`Detection::Empty`]. +//! +//! Detection only looks at the top level on purpose: source files below +//! `src/` etc. carry no extra signal (a `src/main.rs` without `Cargo.toml` +//! is not a Rust project pkh can package), and recursion would turn stray +//! vendored files into false matches. + +use std::path::Path; + +use super::options::TemplateId; + +/// Outcome of the detection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Detection { + /// Exactly one template matches. + Single(TemplateId), + /// Several templates match; the caller must ask (wizard) or demand an + /// explicit `--lang`. + Ambiguous(Vec), + /// Nothing recognized. + Empty, +} + +/// Marker files per template, in precedence order (see the module docs). +const MARKERS: [(TemplateId, &[&str]); 7] = [ + (TemplateId::Rust, &["Cargo.toml"]), + ( + TemplateId::Python, + &["pyproject.toml", "setup.py", "setup.cfg"], + ), + (TemplateId::Meson, &["meson.build"]), + (TemplateId::Cmake, &["CMakeLists.txt"]), + (TemplateId::Autotools, &["configure.ac"]), + (TemplateId::Go, &["go.mod"]), + (TemplateId::Makefile, &["Makefile"]), +]; + +/// Detect the template matching the project in `dir`. +pub fn detect(dir: &Path) -> Detection { + let mut hits: Vec = Vec::new(); + for (id, markers) in MARKERS { + if markers.iter().any(|marker| dir.join(marker).exists()) && !hits.contains(&id) { + hits.push(id); + } + } + + match hits.as_slice() { + [] => {} + [only] => return Detection::Single(*only), + _ => return Detection::Ambiguous(hits), + } + + if single_script(dir).is_some() { + Detection::Single(TemplateId::Shell) + } else { + Detection::Empty + } +} + +/// The single top-level script of `dir`, if there is exactly one: a file +/// with the `.sh` extension, or whose first line starts with `#!`. Returns +/// `None` when there are zero or several candidates. +fn single_script(dir: &Path) -> Option { + let mut found: Option = None; + let entries = std::fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + // Hidden files and packaging leftovers carry no signal. + if name.starts_with('.') { + continue; + } + let is_script = name.ends_with(".sh") || has_shebang(&path); + if is_script { + if found.is_some() { + return None; + } + found = Some(path); + } + } + found +} + +/// Whether the first line of the file starts with `#!`. +fn has_shebang(path: &Path) -> bool { + let Ok(content) = std::fs::read(path) else { + return false; + }; + content.starts_with(b"#!") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn touch(dir: &Path, name: &str) { + std::fs::write(dir.join(name), "x").unwrap(); + } + + #[test] + fn marker_files_map_to_templates() { + let cases = [ + ("Cargo.toml", TemplateId::Rust), + ("pyproject.toml", TemplateId::Python), + ("setup.py", TemplateId::Python), + ("setup.cfg", TemplateId::Python), + ("meson.build", TemplateId::Meson), + ("CMakeLists.txt", TemplateId::Cmake), + ("configure.ac", TemplateId::Autotools), + ("go.mod", TemplateId::Go), + ("Makefile", TemplateId::Makefile), + ]; + for (marker, expected) in cases { + let dir = tempdir().unwrap(); + touch(dir.path(), marker); + assert_eq!(detect(dir.path()), Detection::Single(expected), "{marker}"); + } + } + + #[test] + fn multiple_markers_are_ambiguous() { + let dir = tempdir().unwrap(); + touch(dir.path(), "Cargo.toml"); + touch(dir.path(), "Makefile"); + assert_eq!( + detect(dir.path()), + Detection::Ambiguous(vec![TemplateId::Rust, TemplateId::Makefile]) + ); + + let dir = tempdir().unwrap(); + touch(dir.path(), "pyproject.toml"); + touch(dir.path(), "setup.py"); + // Both markers map to the same template: one hit, not ambiguous. + assert_eq!(detect(dir.path()), Detection::Single(TemplateId::Python)); + + let dir = tempdir().unwrap(); + touch(dir.path(), "meson.build"); + touch(dir.path(), "CMakeLists.txt"); + assert_eq!( + detect(dir.path()), + Detection::Ambiguous(vec![TemplateId::Meson, TemplateId::Cmake]) + ); + } + + #[test] + fn single_script_is_shell() { + // .sh extension. + let dir = tempdir().unwrap(); + touch(dir.path(), "run.sh"); + assert_eq!(detect(dir.path()), Detection::Single(TemplateId::Shell)); + + // Shebang without extension. + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("run"), "#!/usr/bin/env python3\n").unwrap(); + assert_eq!(detect(dir.path()), Detection::Single(TemplateId::Shell)); + + // Two scripts: not exactly one, nothing recognized. + let dir = tempdir().unwrap(); + touch(dir.path(), "a.sh"); + touch(dir.path(), "b.sh"); + assert_eq!(detect(dir.path()), Detection::Empty); + + // Plain files without shebang are not scripts. + let dir = tempdir().unwrap(); + touch(dir.path(), "README"); + assert_eq!(detect(dir.path()), Detection::Empty); + } + + #[test] + fn nothing_matches_is_empty() { + let dir = tempdir().unwrap(); + assert_eq!(detect(dir.path()), Detection::Empty); + + // Nonexistent directory: empty, not a panic. + let dir = tempdir().unwrap(); + assert_eq!(detect(&dir.path().join("missing")), Detection::Empty); + } + + #[test] + fn hidden_files_and_subdirs_are_ignored() { + let dir = tempdir().unwrap(); + std::fs::create_dir(dir.path().join("subdir.sh")).unwrap(); + std::fs::write(dir.path().join(".hidden.sh"), "#!/bin/sh\n").unwrap(); + // The only "real" script candidate is in a subdir or hidden: no hit. + assert_eq!(detect(dir.path()), Detection::Empty); + } +} diff --git a/src/new/git.rs b/src/new/git.rs new file mode 100644 index 0000000..d87ce96 --- /dev/null +++ b/src/new/git.rs @@ -0,0 +1,64 @@ +//! Git handling for `pkh new`: initialize a repository in the scaffolded +//! tree unless it is already inside one (the `.gitignore`s are written +//! regardless). + +use std::path::Path; + +/// Ensure `dir` has a git repository when it should: when `dir` is already +/// inside a work tree (its own or a parent's), nothing is initialized and +/// `Ok(false)` is returned with an info log; otherwise a repository is +/// initialized in `dir` when `init` is set (the `--no-git` case passes +/// `init = false`). +pub fn ensure_repository(dir: &Path, init: bool) -> Result> { + match git2::Repository::discover(dir) { + Ok(_) => { + log::info!( + "Already inside a git repository; skipping git init \ + (the .gitignore files are written anyway)" + ); + Ok(false) + } + Err(_) if init => { + git2::Repository::init(dir)?; + log::info!("Initialized empty git repository in {}", dir.display()); + Ok(true) + } + // --no-git: gitignores only. + Err(_) => Ok(false), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn init_skipped_with_no_git() { + let dir = tempdir().unwrap(); + assert!(!ensure_repository(dir.path(), false).unwrap()); + assert!(!dir.path().join(".git").exists()); + } + + #[test] + fn init_creates_repository() { + let dir = tempdir().unwrap(); + assert!(ensure_repository(dir.path(), true).unwrap()); + assert!(dir.path().join(".git").exists()); + + // A second call discovers the fresh repository and skips init. + assert!(!ensure_repository(dir.path(), true).unwrap()); + } + + #[test] + fn parent_repository_is_discovered() { + let dir = tempdir().unwrap(); + let sub = dir.path().join("sub"); + std::fs::create_dir_all(&sub).unwrap(); + git2::Repository::init(dir.path()).unwrap(); + + // The subdirectory is already inside the parent work tree. + assert!(!ensure_repository(&sub, true).unwrap()); + assert!(!sub.join(".git").exists()); + } +} diff --git a/src/new/mod.rs b/src/new/mod.rs new file mode 100644 index 0000000..a8c2761 --- /dev/null +++ b/src/new/mod.rs @@ -0,0 +1,589 @@ +//! `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), orig +//! tarball creation, git initialization, structural verification and the +//! next-steps message. The interactive wizard and the remaining templates +//! (python, meson, cmake, autotools, go, rust, makefile) are follow-up work +//! built on top of the same [`options::NewOptions`] and +//! [`templates::Template`] surface. + +pub mod debian; +pub mod detect; +pub mod git; +pub mod options; +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 (unimplemented ids fail here, not at parse time), +/// 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. create the orig tarball (quilt only, refusing overwrites), +/// 6. `git init` unless `--no-git` or already inside a repository, +/// 7. run the structural verification, +/// 8. 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: unknown-but-valid ids fail here with the + // friendly message instead of a parse error. + let template = templates::get(opts.template).ok_or_else(|| { + format!( + "The '{}' template is not implemented yet. Implemented templates: {}.", + opts.template, + templates::all() + .iter() + .map(|t| t.id().as_str()) + .collect::>() + .join(", ") + ) + })?; + + // 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. 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())?; + } + + // 6. Git. + pb.set_message("Initializing git"); + git::ensure_repository(&target, opts.git)?; + + // 7. 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, + } + } + + /// 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}"); + + // Unimplemented template. + let dir = tempdir().unwrap(); + let err = scaffold_in( + dir.path(), + opts(TemplateId::Rust, "mytool", SourceDir::Skeleton), + ) + .unwrap_err(); + assert!(err.to_string().contains("not implemented yet"), "{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: 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); + } +} diff --git a/src/new/options.rs b/src/new/options.rs new file mode 100644 index 0000000..63d661f --- /dev/null +++ b/src/new/options.rs @@ -0,0 +1,943 @@ +//! Answers, validators and flag/default resolution for `pkh new`. +//! +//! [`NewCli`] carries the raw command-line answers (every flag optional), +//! [`resolve`] merges them with the built-in defaults and the project +//! detection (see [`crate::new::detect`]) into a fully-specified +//! [`NewOptions`], erroring with the list of every missing required answer +//! when running without an interactive wizard. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use regex::Regex; + +use crate::debian::DebianVersion; +use crate::debian::deps::{Deps, ParseOpts}; +use crate::distro_info; +use crate::new::detect::{self, Detection}; + +/// Build systems / project kinds `pkh new` knows about. +/// +/// The identifiers are stable CLI surface: `--lang` accepts every variant, +/// even the ones that have no template implementation yet (those fail at +/// scaffold time with a "not implemented yet" error instead of a parse +/// error, so scripts written today keep working once they land). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TemplateId { + /// Rust project (`Cargo.toml`) + Rust, + /// Python project (`pyproject.toml` / `setup.py` / `setup.cfg`) + Python, + /// C/C++ with Meson (`meson.build`) + Meson, + /// C/C++ with CMake (`CMakeLists.txt`) + Cmake, + /// C/C++ with Autotools (`configure.ac`) + Autotools, + /// Go module (`go.mod`) + Go, + /// Shell script / single interpreted file + Shell, + /// Generic Makefile-based project + Makefile, + /// Metapackage / empty base (no build system) + Empty, +} + +impl TemplateId { + /// Every template id, in the order offered by the wizard language menu. + pub fn all() -> [TemplateId; 9] { + [ + TemplateId::Rust, + TemplateId::Python, + TemplateId::Meson, + TemplateId::Cmake, + TemplateId::Autotools, + TemplateId::Go, + TemplateId::Shell, + TemplateId::Makefile, + TemplateId::Empty, + ] + } + + /// Canonical CLI identifier of this template. + pub fn as_str(&self) -> &'static str { + match self { + TemplateId::Rust => "rust", + TemplateId::Python => "python", + TemplateId::Meson => "meson", + TemplateId::Cmake => "cmake", + TemplateId::Autotools => "autotools", + TemplateId::Go => "go", + TemplateId::Shell => "shell", + TemplateId::Makefile => "makefile", + TemplateId::Empty => "empty", + } + } + + /// Parse a CLI identifier, accepting exactly the canonical spellings. + pub fn parse(s: &str) -> Result { + TemplateId::all() + .into_iter() + .find(|id| id.as_str() == s) + .ok_or_else(|| { + format!( + "Unknown language/template '{}'. Supported values are: {}.", + s, + TemplateId::all() + .iter() + .map(|id| id.as_str()) + .collect::>() + .join(", ") + ) + }) + } + + /// Whether a template implementation (and therefore scaffolding) exists + /// for this id. The remaining templates land with the follow-up work. + pub fn implemented(&self) -> bool { + matches!(self, TemplateId::Shell | TemplateId::Empty) + } +} + +impl std::fmt::Display for TemplateId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Where the upstream sources come from, and where the package tree lives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SourceDir { + /// Generate a fresh project skeleton in `.//`. + Skeleton, + /// Package the sources already sitting in the current directory. + Here, + /// Package the sources of another directory. + Path(PathBuf), +} + +/// Upstream license of the package: a curated SPDX list plus a free-text +/// fallback for anything else (including "unknown" until the user picks one). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum License { + /// MIT + Mit, + /// Apache-2.0 + Apache2, + /// GPL-2.0+ + Gpl2Plus, + /// GPL-3.0+ + Gpl3Plus, + /// LGPL-2.1+ + Lgpl21Plus, + /// LGPL-3.0+ + Lgpl3Plus, + /// BSD-2-Clause + Bsd2Clause, + /// BSD-3-Clause + Bsd3Clause, + /// ISC + Isc, + /// Any other license, spelled verbatim (also the fallback for + /// unrecognized `--license` values). + Custom(String), +} + +impl License { + /// Map a license string to a [`License`]: curated SPDX identifiers are + /// matched case-insensitively, anything else becomes + /// [`License::Custom`] verbatim. + pub fn parse(s: &str) -> License { + match s.to_ascii_lowercase().as_str() { + "mit" => License::Mit, + "apache-2.0" => License::Apache2, + "gpl-2.0+" => License::Gpl2Plus, + "gpl-3.0+" => License::Gpl3Plus, + "lgpl-2.1+" => License::Lgpl21Plus, + "lgpl-3.0+" => License::Lgpl3Plus, + "bsd-2-clause" => License::Bsd2Clause, + "bsd-3-clause" => License::Bsd3Clause, + "isc" => License::Isc, + other => License::Custom(other.to_string()), + } + } + + /// SPDX identifier used in `debian/copyright`. + pub fn spdx(&self) -> &str { + match self { + License::Mit => "MIT", + License::Apache2 => "Apache-2.0", + License::Gpl2Plus => "GPL-2.0+", + License::Gpl3Plus => "GPL-3.0+", + License::Lgpl21Plus => "LGPL-2.1+", + License::Lgpl3Plus => "LGPL-3.0+", + License::Bsd2Clause => "BSD-2-Clause", + License::Bsd3Clause => "BSD-3-Clause", + License::Isc => "ISC", + License::Custom(s) => s, + } + } + + /// SPDX license data page URL (without the trailing `+` of the + /// "or later" spellings), for the copyright reference paragraph. + pub fn spdx_url(&self) -> String { + format!( + "https://spdx.org/licenses/{}.html", + self.spdx().trim_end_matches('+') + ) + } +} + +/// Fully-specified options of a `pkh new` run: the CLI answers after +/// defaults resolution and validation. +#[derive(Debug, Clone)] +pub struct NewOptions { + /// Source package name (validated). + pub name: String, + /// Template (language / build system) to scaffold. + pub template: TemplateId, + /// Where the upstream sources come from, and where the tree lives. + pub source_dir: SourceDir, + /// Upstream version (validated with [`DebianVersion`]). + pub upstream_version: String, + /// Debian revision, default 1. + pub revision: u32, + /// One-line description (the control `Description` synopsis). + pub summary: String, + /// Long description; defaults to the summary. + pub long_description: String, + /// Upstream homepage, when known. + pub homepage: Option, + /// Upstream license. + pub license: License, + /// Installed command name, default = name (ignored by the empty + /// template). + pub command: String, + /// Maintainer (name, email). + pub maintainer: (String, String), + /// Target distribution (e.g. `ubuntu`, `debian`). + pub dist: String, + /// Target series: drives the printed next steps (and, with + /// [`NewOptions::release`], the changelog distribution). + pub series: String, + /// Write `series` into the changelog instead of UNRELEASED. + pub release: bool, + /// Runtime Depends clauses of the metapackage flavor (canonically + /// rendered; empty for every other flavor). + pub depends: Vec, + /// Use the `3.0 (native)` source format (no orig tarball). + pub native: bool, + /// Initialize a git repository (gitignores are written regardless). + pub git: bool, +} + +impl NewOptions { + /// Directory the package tree lives in: `.//` for the skeleton + /// mode, the given directory otherwise. + pub fn target_dir(&self, cwd: &Path) -> PathBuf { + match &self.source_dir { + SourceDir::Skeleton => cwd.join(&self.name), + SourceDir::Here => cwd.to_path_buf(), + SourceDir::Path(p) => p.clone(), + } + } + + /// Full Debian version of the initial entry: `-`. + pub fn full_version(&self) -> String { + format!("{}-{}", self.upstream_version, self.revision) + } + + /// Upstream version as used in artifact file names (dpkg drops the + /// epoch from file names). + pub fn upstream_version_no_epoch(&self) -> String { + DebianVersion::parse(&self.full_version()) + .map(|v| v.upstream) + .unwrap_or_else(|_| self.upstream_version.clone()) + } +} + +/// Raw command-line answers of `pkh new`, before defaults resolution. +/// +/// Every field is optional so the interactive wizard (follow-up work) can +/// fill the same structure from its questions. +#[derive(Debug, Clone, Default)] +pub struct NewCli { + /// Positional ``. + pub name: Option, + /// `--lang` value (parsed by [`resolve`]). + pub lang: Option, + /// `--source `. + pub source: Option, + /// `--upstream-version `. + pub upstream_version: Option, + /// `--revision `. + pub revision: Option, + /// `--description `. + pub description: Option, + /// `--homepage `. + pub homepage: Option, + /// `--license `. + pub license: Option, + /// `--command `. + pub command: Option, + /// `--maintainer "Name "`. + pub maintainer: Option, + /// `--depends` values; each entry may itself be a comma-separated list. + pub depends: Vec, + /// `--dist `. + pub dist: Option, + /// `--series `. + pub series: Option, + /// `--release`. + pub release: bool, + /// `--native`. + pub native: bool, + /// True unless `--no-git`. + pub git: bool, + /// `--defaults`. + pub defaults: bool, +} + +/// Validate a Debian source package name: `^[a-z0-9][a-z0-9+.\-]+$` with a +/// minimum length of 2 (dpkg rules). +pub fn validate_source_name(name: &str) -> Result<(), String> { + static NAME_REGEX: std::sync::OnceLock = std::sync::OnceLock::new(); + let regex = NAME_REGEX.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9+.\-]+$").unwrap()); + if name.len() < 2 { + return Err(format!( + "'{name}' is too short: package names need at least 2 characters" + )); + } + if !regex.is_match(name) { + return Err(format!( + "'{name}' is not a valid package name: names must be lowercase, \ + start with a letter or digit and may only contain [a-z0-9+.-]" + )); + } + Ok(()) +} + +/// Try to turn an arbitrary string (e.g. a directory name like `My Tool`) +/// into a valid package name: lowercased, whitespace/underscores turned into +/// `-`, leading/trailing `-` trimmed and repeated `-` collapsed. `None` when +/// nothing valid remains. +pub fn sanitize_name(input: &str) -> Option { + let mut out = String::with_capacity(input.len()); + let mut last_dash = false; + for c in input.chars() { + let c = c.to_ascii_lowercase(); + if c.is_ascii_lowercase() || c.is_ascii_digit() { + out.push(c); + last_dash = false; + } else if c == '.' || c == '+' { + // Dpkg-valid characters kept verbatim. + out.push(c); + last_dash = false; + } else if c.is_whitespace() || c == '_' || c == '-' { + // Collapse whitespace/underscore runs into a single dash. + if !out.is_empty() && !last_dash { + out.push('-'); + last_dash = true; + } + } + // Characters outside the dpkg name charset (everything but + // [a-z0-9+.-]) are dropped: a directory name is not a controlled + // input, so stay conservative there. + } + while out.ends_with('-') { + out.pop(); + } + while out.starts_with('-') { + out.remove(0); + } + // A leading digit would be valid for dpkg but a name made only of + // digits/dots is rejected by the validator below anyway. + validate_source_name(&out).ok()?; + Some(out) +} + +/// Validate an upstream version: it must start with a digit (dpkg +/// recommendation, enforced here) and survive [`DebianVersion::parse`] once +/// composed with the Debian revision. It must not contain `-` (the revision +/// separator). +pub fn validate_upstream_version(upstream: &str, revision: u32) -> Result<(), String> { + if !upstream.starts_with(|c: char| c.is_ascii_digit()) { + return Err("upstream versions should start with a digit".to_string()); + } + if upstream.contains('-') { + return Err( + "upstream versions must not contain '-': the Debian revision is \ + appended automatically" + .to_string(), + ); + } + let composed = format!("{}-{}", upstream, revision); + DebianVersion::parse(&composed) + .map(|_| ()) + .map_err(|e| format!("'{composed}' is not a valid Debian version: {e}")) +} + +/// Parse a `Name ` maintainer string: the name must be non-empty and +/// the email must contain exactly one `@` with no whitespace inside the +/// angle brackets. +pub fn parse_maintainer(s: &str) -> Result<(String, String), String> { + static MAINTAINER_REGEX: std::sync::OnceLock = std::sync::OnceLock::new(); + let regex = MAINTAINER_REGEX.get_or_init(|| Regex::new(r"^(.+?)\s*<([^<>]*)>\s*$").unwrap()); + let caps = regex.captures(s.trim()).ok_or_else(|| { + format!("'{s}' is not a valid maintainer: expected the form 'Name '") + })?; + let name = caps[1].trim().to_string(); + let email = caps[2].trim().to_string(); + if name.is_empty() { + return Err(format!("'{s}': the maintainer name must not be empty")); + } + if !email.contains('@') || email.matches('@').count() > 1 { + return Err(format!( + "'{s}': the maintainer email must contain exactly one '@'" + )); + } + if email.chars().any(char::is_whitespace) { + return Err(format!( + "'{s}': the maintainer email must not contain whitespace" + )); + } + Ok((name, email)) +} + +/// Validate a homepage URL: it must carry an `http(s)://` prefix. +pub fn validate_homepage(url: &str) -> Result<(), String> { + if url.starts_with("http://") || url.starts_with("https://") { + Ok(()) + } else { + Err(format!( + "'{url}' is not a valid homepage: expected an http:// or https:// URL" + )) + } +} + +/// Validate a comma-separated runtime Depends list (the metapackage flavor) +/// by parsing it with [`Deps::parse`] and re-rendering every clause +/// canonically. +pub fn validate_depends(list: &str) -> Result, String> { + let opts = ParseOpts { + host_arch: crate::get_current_arch(), + build_arch: crate::get_current_arch(), + build_profiles: Vec::new(), + reduce_restrictions: false, + union: false, + build_dep: false, + }; + let deps = + Deps::parse(list, &opts).map_err(|e| format!("Invalid Depends list '{list}': {e}"))?; + Ok(deps + .clauses() + .map(|clause| { + clause + .iter() + .map(|rel| rel.output()) + .collect::>() + .join(" | ") + }) + .collect()) +} + +/// Resolve the CLI answers into fully-specified [`NewOptions`]: explicit +/// flags win, then detection (when `--lang` is not given), then defaults. +/// +/// Missing required answers (name, template, description) are all reported +/// in a single error so non-interactive callers can fix everything at once. +pub async fn resolve(cli: NewCli) -> Result { + let mut missing: Vec = Vec::new(); + + // Where do the sources come from? + let source_dir = match &cli.source { + Some(p) => SourceDir::Path(p.clone()), + None if cli.name.is_some() => SourceDir::Skeleton, + None => SourceDir::Here, + }; + + // Name: positional argument, else (with --defaults) the sanitized + // basename of the current directory. + let name = match &cli.name { + Some(n) => n.clone(), + None if cli.defaults => { + let cwd = std::env::current_dir() + .map_err(|e| format!("Could not determine the current directory: {e}"))?; + let base = cwd + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| "Could not derive a package name from the current directory; pass a name argument".to_string())?; + sanitize_name(base).ok_or_else(|| { + format!( + "Cannot derive a valid package name from the directory name \ + '{base}'; pass a name argument" + ) + })? + } + None => { + missing.push( + "package name (pass it as a positional argument, or use \ + --defaults to derive it from the current directory name)" + .to_string(), + ); + String::new() + } + }; + + // Template: --lang wins, else project detection in the source directory + // (nothing to detect for a fresh skeleton), else --defaults picks the + // empty template. + let template = match &cli.lang { + Some(lang) => Some(TemplateId::parse(lang)?), + None => match &source_dir { + SourceDir::Skeleton => { + if cli.defaults { + log::info!("No language given, --defaults picks the 'empty' template"); + Some(TemplateId::Empty) + } else { + missing.push(format!( + "--lang <{}|...> (no language given and there is nothing \ + to detect for a new project skeleton)", + TemplateId::all() + .iter() + .map(|id| id.as_str()) + .collect::>() + .join("|") + )); + None + } + } + other => { + let dir = match other { + SourceDir::Here => std::env::current_dir() + .map_err(|e| format!("Could not determine the current directory: {e}"))?, + SourceDir::Path(p) => p.clone(), + SourceDir::Skeleton => unreachable!("handled above"), + }; + match detect::detect(&dir) { + Detection::Single(id) => { + log::info!("Detected: {} project in {}", id, dir.display()); + Some(id) + } + Detection::Ambiguous(candidates) => { + return Err(format!( + "Ambiguous project detection in '{}': multiple build \ + systems found ({}). Pass --lang explicitly.", + dir.display(), + candidates + .iter() + .map(|id| id.as_str()) + .collect::>() + .join(", ") + )); + } + Detection::Empty => { + if cli.defaults { + log::info!( + "No recognized project in {}, --defaults picks \ + the 'empty' template", + dir.display() + ); + Some(TemplateId::Empty) + } else { + missing.push( + "--lang (could not detect a build system; \ + run inside a project directory or pick one)" + .to_string(), + ); + None + } + } + } + } + }, + }; + + // One-line description: required without the wizard. The long + // description defaults to the summary. + let long_description = cli.description.clone().unwrap_or_default(); + let summary = match &cli.description { + Some(d) => d.clone(), + None => { + missing.push("--description ".to_string()); + String::new() + } + }; + + if !missing.is_empty() { + return Err(format!( + "Missing required answers (use --defaults to take every default, \ + or answer interactively once the wizard lands):\n - {}", + missing.join("\n - ") + )); + } + + // Everything below has a default and is validated as it is resolved. + validate_source_name(&name)?; + + let upstream_version = cli.upstream_version.unwrap_or_else(|| "0.1.0".to_string()); + let revision = cli.revision.unwrap_or(1); + validate_upstream_version(&upstream_version, revision) + .map_err(|e| format!("Invalid upstream version: {e}"))?; + + let homepage = match &cli.homepage { + Some(h) => { + validate_homepage(h)?; + Some(h.clone()) + } + None => None, + }; + + let license = License::parse(cli.license.as_deref().unwrap_or("unknown")); + + let command = cli.command.unwrap_or_else(|| name.clone()); + + let maintainer = match &cli.maintainer { + Some(m) => parse_maintainer(m)?, + None => crate::changelog::get_maintainer_info().map_err(|e| { + format!( + "Could not determine the maintainer ({e}). \ + Pass --maintainer \"Name \"." + ) + })?, + }; + + let dist = cli + .dist + .unwrap_or_else(|| crate::build::env::current_vendor().to_lowercase()); + let series = match &cli.series { + Some(s) => s.clone(), + None if cli.release => { + return Err( + "--release requires --series: there is no series to release to".to_string(), + ); + } + None => distro_info::effective_series(distro_info::UNRELEASED, &dist) + .await + .map_err(|e| { + format!("Could not resolve the default target series for '{dist}': {e}") + })?, + }; + + // The metapackage Depends list: comma-joined, parsed and re-rendered + // canonically. + let joined = cli.depends.join(", "); + let depends = if joined.trim().is_empty() { + Vec::new() + } else { + validate_depends(&joined)? + }; + + Ok(NewOptions { + name, + template: template.unwrap_or(TemplateId::Empty), + source_dir, + upstream_version, + revision, + summary, + long_description, + homepage, + license, + command, + maintainer, + dist, + series, + release: cli.release, + depends, + native: cli.native, + git: cli.git, + }) +} + +/// Set of names no template may produce twice (collision check). +pub(crate) fn check_file_collisions(paths: &[String]) -> Result<(), String> { + let mut seen: HashSet<&String> = HashSet::new(); + for path in paths { + if !seen.insert(path) { + return Err(format!( + "internal error: file '{path}' was generated more than once" + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn template_ids_roundtrip() { + for id in TemplateId::all() { + assert_eq!(TemplateId::parse(id.as_str()).unwrap(), id); + } + assert!(TemplateId::parse("cobol").is_err()); + // Every id is accepted by the parser; only some are implemented. + assert!(!TemplateId::Rust.implemented()); + assert!(TemplateId::Shell.implemented()); + assert!(TemplateId::Empty.implemented()); + } + + #[test] + fn source_name_validator() { + for valid in ["mytool", "my-tool", "my.tool", "my+tool", "a1", "pkh9x"] { + assert!(validate_source_name(valid).is_ok(), "{valid} must pass"); + } + for invalid in [ + "A", + "a", // too short + "UPPER", + "Mixed", // uppercase + "-lead", // bad first char + "sp ace", + "under_score", + "bang!", + "", + "é", // bad charset + ] { + assert!( + validate_source_name(invalid).is_err(), + "{invalid} must fail" + ); + } + } + + #[test] + fn sanitize_name_derives_valid_names() { + assert_eq!(sanitize_name("My Tool"), Some("my-tool".to_string())); + assert_eq!(sanitize_name("My_Tool"), Some("my-tool".to_string())); + assert_eq!( + sanitize_name(" spaced out "), + Some("spaced-out".to_string()) + ); + assert_eq!( + sanitize_name("trailing---dashes--"), + Some("trailing-dashes".to_string()) + ); + assert_eq!(sanitize_name("v1.2_beta"), Some("v1.2-beta".to_string())); + // Nothing sane remains. + assert_eq!(sanitize_name("---"), None); + assert_eq!(sanitize_name("A"), None); + assert_eq!(sanitize_name(""), None); + } + + #[test] + fn upstream_version_validator() { + assert!(validate_upstream_version("0.1.0", 1).is_ok()); + assert!(validate_upstream_version("1.0~rc1", 1).is_ok()); + assert!(validate_upstream_version("20260101", 1).is_ok()); + // Not starting with a digit. + assert!(validate_upstream_version("v1.0", 1).is_err()); + // Revision separator inside the upstream version. + assert!(validate_upstream_version("1.0-2", 1).is_err()); + // Invalid characters survive composition. + assert!(validate_upstream_version("1.0_0", 1).is_err()); + // Epochs are accepted and compose fine. + assert!(validate_upstream_version("1:2.0", 1).is_ok()); + // The full version round-trips through DebianVersion. + let opts = NewOptions { + name: "t".into(), + template: TemplateId::Empty, + source_dir: SourceDir::Here, + upstream_version: "0.1.0".into(), + revision: 1, + summary: "s".into(), + long_description: "s".into(), + homepage: None, + license: License::Mit, + command: "t".into(), + maintainer: ("A".into(), "a@b.c".into()), + dist: "debian".into(), + series: "sid".into(), + release: false, + depends: Vec::new(), + native: false, + git: false, + }; + assert_eq!(opts.full_version(), "0.1.0-1"); + assert_eq!( + DebianVersion::parse(&opts.full_version()).unwrap().full(), + "0.1.0-1" + ); + // Epochs never leak into file names. + let epochy = NewOptions { + upstream_version: "1:2.0".into(), + ..opts.clone() + }; + assert_eq!(epochy.upstream_version_no_epoch(), "2.0"); + assert_eq!( + DebianVersion::parse(&epochy.full_version()).unwrap().epoch, + Some(1) + ); + } + + #[test] + fn maintainer_parser() { + assert_eq!( + parse_maintainer("Jane Doe ").unwrap(), + ("Jane Doe".to_string(), "jane@example.com".to_string()) + ); + assert_eq!( + parse_maintainer(" X ").unwrap(), + ("X".to_string(), "x@y.z".to_string()) + ); + // Bad forms. + assert!(parse_maintainer("Jane Doe").is_err()); // no email + assert!(parse_maintainer("").is_err()); // no name + assert!(parse_maintainer("Jane ").is_err()); // no @ + assert!(parse_maintainer("Jane ").is_err()); // two @s + assert!(parse_maintainer("Jane ").is_err()); // space + assert!(parse_maintainer("Jane <>").is_err()); // empty email + } + + #[test] + fn homepage_validator() { + assert!(validate_homepage("https://example.com").is_ok()); + assert!(validate_homepage("http://example.com/x").is_ok()); + assert!(validate_homepage("ftp://example.com").is_err()); + assert!(validate_homepage("example.com").is_err()); + assert!(validate_homepage("").is_err()); + } + + #[test] + fn depends_parse_and_canonical_rerender() { + let clauses = validate_depends("foo (>= 1.0), bar").unwrap(); + assert_eq!(clauses, vec!["foo (>= 1.0)", "bar"]); + // Canonicalization: legacy relations and odd spacing normalize. + let clauses = validate_depends("a(> 1), b | c").unwrap(); + assert_eq!(clauses, vec!["a (>= 1)", "b | c"]); + // Invalid clauses are rejected. + assert!(validate_depends("foo (>= ), bar").is_err()); + assert!(validate_depends("a, ,@!").is_err()); + // Empty list. + assert!(validate_depends("").unwrap().is_empty()); + } + + #[test] + fn license_parsing() { + assert_eq!(License::parse("MIT"), License::Mit); + assert_eq!(License::parse("mit"), License::Mit); + assert_eq!(License::parse("GPL-3.0+"), License::Gpl3Plus); + assert_eq!(License::parse("bsd-3-clause"), License::Bsd3Clause); + assert_eq!(License::parse("Zlib"), License::Custom("zlib".to_string())); + assert_eq!(License::Mit.spdx(), "MIT"); + assert_eq!(License::Gpl2Plus.spdx(), "GPL-2.0+"); + assert_eq!( + License::Gpl2Plus.spdx_url(), + "https://spdx.org/licenses/GPL-2.0.html" + ); + } + + #[test] + fn collision_detection() { + assert!(check_file_collisions(&["a".into(), "b".into()]).is_ok()); + let err = check_file_collisions(&["a".into(), "b".into(), "a".into()]).unwrap_err(); + assert!(err.contains('a')); + } + + #[tokio::test] + async fn resolve_reports_every_missing_answer() { + // --source an empty directory: detection finds nothing, so the + // language joins the missing answers (and all of them are listed + // at once). + let dir = tempfile::tempdir().unwrap(); + let cli = NewCli { + source: Some(dir.path().to_path_buf()), + ..Default::default() + }; + let err = resolve(cli).await.unwrap_err(); + assert!(err.contains("package name"), "{err}"); + assert!(err.contains("--lang"), "{err}"); + assert!(err.contains("--description"), "{err}"); + } + + #[tokio::test] + async fn resolve_defaults_fill_everything_but_description() { + // With --defaults the language defaults to 'empty' when detection + // finds nothing; the description stays required without a wizard. + let dir = tempfile::tempdir().unwrap(); + let cli = NewCli { + name: Some("mytool".into()), + source: Some(dir.path().to_path_buf()), + defaults: true, + git: true, + ..Default::default() + }; + let err = resolve(cli).await.unwrap_err(); + assert!(err.contains("--description"), "{err}"); + assert!(!err.contains("--lang"), "{err}"); + } + + #[tokio::test] + async fn resolve_merges_flags_defaults_and_detection() { + // Detection against an empty --source directory with --defaults + // picks the empty template. + let dir = tempfile::tempdir().unwrap(); + let cli = NewCli { + name: Some("my-tool".into()), + source: Some(dir.path().to_path_buf()), + defaults: true, + description: Some("Does things".into()), + depends: vec!["hello (>= 1.0), hello-data".into()], + git: true, + ..Default::default() + }; + let opts = resolve(cli).await.unwrap(); + + assert_eq!(opts.name, "my-tool"); + assert_eq!(opts.template, TemplateId::Empty); + assert!(matches!(opts.source_dir, SourceDir::Path(_))); + assert_eq!(opts.upstream_version, "0.1.0"); + assert_eq!(opts.revision, 1); + assert_eq!(opts.summary, "Does things"); + assert_eq!(opts.long_description, "Does things"); + assert_eq!(opts.homepage, None); + assert_eq!(opts.license, License::Custom("unknown".into())); + assert_eq!(opts.command, "my-tool"); + assert_eq!(opts.depends, vec!["hello (>= 1.0)", "hello-data"]); + assert!(opts.git); + assert!(!opts.release); + // Series: the development series of the current vendor (lowercased, + // matching the distro-info keys). + let dist = crate::build::env::current_vendor().to_lowercase(); + assert_eq!(opts.dist, dist); + assert_eq!( + opts.series, + crate::distro_info::effective_series(crate::distro_info::UNRELEASED, &dist) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn resolve_detects_the_project_in_source_dir() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("go.mod"), "module example.com/x\n").unwrap(); + let cli = NewCli { + name: Some("xtool".into()), + source: Some(dir.path().to_path_buf()), + description: Some("An X".into()), + maintainer: Some("Jane ".into()), + git: true, + ..Default::default() + }; + let opts = resolve(cli).await.unwrap(); + assert_eq!(opts.template, TemplateId::Go); + } + + #[tokio::test] + async fn release_requires_series() { + let cli = NewCli { + name: Some("mytool".into()), + lang: Some("empty".into()), + description: Some("A tool".into()), + release: true, + maintainer: Some("Jane ".into()), + git: true, + ..Default::default() + }; + let err = resolve(cli).await.unwrap_err(); + assert!(err.contains("--release requires --series"), "{err}"); + } +} diff --git a/src/new/templates/empty.rs b/src/new/templates/empty.rs new file mode 100644 index 0000000..bb15b1f --- /dev/null +++ b/src/new/templates/empty.rs @@ -0,0 +1,84 @@ +//! The `empty` template: a metapackage or an empty base package with no +//! build system at all. +//! +//! One template with two flavors: a non-empty `Depends` list selects the +//! **metapackage** flavor (the canonical `Architecture: all`, nothing +//! compiled, the Depends list *is* the payload shape), while an empty list +//! selects the **empty base** — pure `dh $@` plumbing as a starting point +//! for hand-written rules. + +use super::{OutputFile, Template}; +use crate::new::options::NewOptions; + +/// Metapackage / empty base (no build system). +pub struct Empty; + +impl Template for Empty { + fn id(&self) -> crate::new::options::TemplateId { + crate::new::options::TemplateId::Empty + } + + /// No upstream files; just a stub `README` marking the tree as + /// intentionally empty. + fn skeleton(&self, opts: &NewOptions) -> Vec { + vec![OutputFile::new( + "README", + format!( + "{} - empty base tree scaffolded by `pkh new`; there is \ + intentionally no upstream build system here.\n", + opts.name + ), + )] + } + + /// No extra debian/ files: the metapackage `Depends` list is carried by + /// [`NewOptions::depends`] into the common `debian/control` rendering. + fn debian(&self, _opts: &NewOptions) -> Vec { + Vec::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::new::options::{License, SourceDir, TemplateId}; + + fn opts(depends: Vec) -> NewOptions { + NewOptions { + name: "metapkg".into(), + template: TemplateId::Empty, + source_dir: SourceDir::Skeleton, + upstream_version: "0.1.0".into(), + revision: 1, + summary: "A metapackage".into(), + long_description: "A metapackage".into(), + homepage: None, + license: License::Custom("unknown".into()), + command: "ignored".into(), + maintainer: ("Jane".into(), "jane@example.com".into()), + dist: "debian".into(), + series: "sid".into(), + release: false, + depends, + native: false, + git: true, + } + } + + #[test] + fn empty_template_shape() { + let template = super::super::get(TemplateId::Empty).unwrap(); + + // Metapackage flavor: the depends list travels in the options. + let o = opts(vec!["hello".into(), "hello-data (>= 1.0)".into()]); + assert!(template.debian(&o).is_empty()); + assert_eq!(template.architecture(), "all"); + assert!(template.build_depends(&o).is_empty()); + + let skeleton = template.skeleton(&o); + assert_eq!(skeleton.len(), 1); + assert_eq!(skeleton[0].path, "README"); + assert!(!skeleton[0].executable); + assert!(skeleton[0].contents.contains("metapkg")); + } +} diff --git a/src/new/templates/mod.rs b/src/new/templates/mod.rs new file mode 100644 index 0000000..aef8aee --- /dev/null +++ b/src/new/templates/mod.rs @@ -0,0 +1,164 @@ +//! Per-ecosystem template registry for `pkh new`. +//! +//! Every template implements [`Template`]: it renders the upstream-side +//! skeleton files, the extra `debian/` files beyond the common set, and (in +//! the future) probes an existing project for metadata. Rendering is plain +//! `format!` composition — no template engine, matching the codebase style. + +pub mod empty; +pub mod shell; + +use std::path::Path; + +use super::options::{NewOptions, TemplateId}; + +/// One generated file, rendered in memory before anything touches the disk. +#[derive(Debug, Clone)] +pub struct OutputFile { + /// Path relative to the package tree root (e.g. `debian/control`). + pub path: String, + /// Full file contents. + pub contents: String, + /// Whether the file carries the executable bit (mode 0755). + pub executable: bool, +} + +impl OutputFile { + /// A regular (non-executable) file. + pub fn new(path: impl Into, contents: impl Into) -> OutputFile { + OutputFile { + path: path.into(), + contents: contents.into(), + executable: false, + } + } + + /// An executable file (mode 0755). + pub fn executable(path: impl Into, contents: impl Into) -> OutputFile { + OutputFile { + executable: true, + ..OutputFile::new(path, contents) + } + } +} + +/// Metadata extracted from an existing project by [`Template::probe`], used +/// by the interactive wizard to pre-fill its answers (explicit flags always +/// win). The per-template extraction is follow-up work; the hook already +/// exists so templates can grow it independently. +#[derive(Debug, Clone, Default)] +pub struct ProbeResult { + /// Project name (e.g. the `name` key of `Cargo.toml`). + pub name: Option, + /// Project version. + pub version: Option, + /// Project description. + pub description: Option, + /// Project homepage. + pub homepage: Option, + /// Project license (SPDX identifier). + pub license: Option, +} + +/// A package template: one supported ecosystem / build system. +/// +/// `Sync` is required so templates can live in the static registry. +pub trait Template: Sync { + /// Identifier of this template. + fn id(&self) -> TemplateId; + + /// Upstream-side files for the skeleton mode (e.g. `Cargo.toml`, + /// `src/main.rs`). Only called when packaging a fresh skeleton. + fn skeleton(&self, opts: &NewOptions) -> Vec; + + /// Extra `debian/` files beyond the common set rendered by + /// [`super::debian`] (e.g. `debian/install`). + fn debian(&self, opts: &NewOptions) -> Vec; + + /// Build-Depends beyond `debhelper-compat (= 13)`. + fn build_depends(&self, _opts: &NewOptions) -> Vec { + Vec::new() + } + + /// Architecture of the binary package (`all` or `any`). + fn architecture(&self) -> &'static str { + "all" + } + + /// Lines appended to `debian/rules` after the default `dh $@` stanza. + fn rules_extra(&self) -> String { + String::new() + } + + /// Extra defaults derived from project metadata in `dir` (detect.rs); + /// `None` when the project carries nothing this template can read. + fn probe(&self, _dir: &Path) -> Option { + None + } +} + +/// Static instance of the shell template. +pub static SHELL: shell::Shell = shell::Shell; +/// Static instance of the empty/metapackage template. +pub static EMPTY: empty::Empty = empty::Empty; + +/// Every implemented template (the wizard language menu lists +/// [`TemplateId::all()`] and greys the rest out). +static TEMPLATES: &[&dyn Template] = &[&SHELL, &EMPTY]; + +/// Look up the template implementation for `id`; `None` for the ids whose +/// template is not implemented yet (callers turn this into the friendly +/// "not implemented yet" error). +pub fn get(id: TemplateId) -> Option<&'static dyn Template> { + match id { + TemplateId::Shell => Some(&SHELL), + TemplateId::Empty => Some(&EMPTY), + TemplateId::Rust + | TemplateId::Python + | TemplateId::Meson + | TemplateId::Cmake + | TemplateId::Autotools + | TemplateId::Go + | TemplateId::Makefile => None, + } +} + +/// Every implemented template. +pub fn all() -> &'static [&'static dyn Template] { + TEMPLATES +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_covers_implemented_templates() { + for id in [TemplateId::Shell, TemplateId::Empty] { + assert!(get(id).is_some(), "{id} must be registered"); + assert_eq!(get(id).unwrap().id(), id); + } + for id in [ + TemplateId::Rust, + TemplateId::Python, + TemplateId::Meson, + TemplateId::Cmake, + TemplateId::Autotools, + TemplateId::Go, + TemplateId::Makefile, + ] { + assert!(get(id).is_none(), "{id} must not pretend to be implemented"); + } + assert_eq!(all().len(), 2); + } + + #[test] + fn probe_defaults_to_none() { + assert!( + get(TemplateId::Shell) + .unwrap() + .probe(Path::new("/")) + .is_none() + ); + } +} diff --git a/src/new/templates/shell.rs b/src/new/templates/shell.rs new file mode 100644 index 0000000..6c9f966 --- /dev/null +++ b/src/new/templates/shell.rs @@ -0,0 +1,91 @@ +//! The `shell` template: a single interpreted script installed to +//! `/usr/bin` with plain `dh $@` plumbing. + +use super::{OutputFile, Template}; +use crate::new::options::{NewOptions, SourceDir}; + +/// Shell script / single interpreted file. +pub struct Shell; + +impl Template for Shell { + fn id(&self) -> crate::new::options::TemplateId { + crate::new::options::TemplateId::Shell + } + + /// A minimal executable script named after the command, with a `#!/bin/sh` + /// shebang and an `echo` placeholder. + fn skeleton(&self, opts: &NewOptions) -> Vec { + vec![OutputFile::executable( + format!("{}.sh", opts.command), + format!( + "#!/bin/sh\n# Placeholder for {}, generated by `pkh new`.\n\ + echo \"Hello from {}!\"\n", + opts.name, opts.command + ), + )] + } + + /// `debian/install` mapping the script into `/usr/bin/` + /// (debian/install renames when the destination carries a file name). + /// Only for the skeleton mode: when packaging an existing tree the + /// generated mapping would reference the non-existent skeleton script, + /// so the user writes their own install file instead. + fn debian(&self, opts: &NewOptions) -> Vec { + if !matches!(opts.source_dir, SourceDir::Skeleton) { + return Vec::new(); + } + vec![OutputFile::new( + "debian/install", + format!("{}.sh usr/bin/{}\n", opts.command, opts.command), + )] + } +} + +#[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".into(), + long_description: "A tool".into(), + homepage: None, + license: License::Mit, + command: "mytool".into(), + maintainer: ("Jane".into(), "jane@example.com".into()), + dist: "ubuntu".into(), + series: "resolute".into(), + release: false, + depends: Vec::new(), + native: false, + git: true, + } + } + + #[test] + fn shell_template_shape() { + let o = opts(); + let template = super::super::get(TemplateId::Shell).unwrap(); + + assert_eq!(template.architecture(), "all"); + assert!(template.build_depends(&o).is_empty()); + assert!(template.rules_extra().is_empty()); + + let skeleton = template.skeleton(&o); + assert_eq!(skeleton.len(), 1); + assert_eq!(skeleton[0].path, "mytool.sh"); + assert!(skeleton[0].executable); + assert!(skeleton[0].contents.starts_with("#!/bin/sh\n")); + + let debian = template.debian(&o); + assert_eq!(debian.len(), 1); + assert_eq!(debian[0].path, "debian/install"); + assert_eq!(debian[0].contents, "mytool.sh usr/bin/mytool\n"); + } +} diff --git a/src/new/verify.rs b/src/new/verify.rs new file mode 100644 index 0000000..89a82a0 --- /dev/null +++ b/src/new/verify.rs @@ -0,0 +1,214 @@ +//! Structural self-checks of a freshly scaffolded package tree (step 1 of +//! the spec's "try very hard" verification): every check is cheap, local +//! and re-parses the generated files with the same parsers the build +//! pipeline uses, so `pkh build` failures are caught at generation time +//! when they come from a pkh bug rather than from the tree. + +use std::path::Path; + +use crate::debian::{ControlInfo, DebianVersion}; + +/// Verify the structural sanity of the scaffolded tree at `tree`: +/// +/// - `debian/rules` exists and carries the executable bit, +/// - `debian/control` parses as deb822 (source stanza + binary stanza), +/// - `debian/changelog` parses, +/// - `debian/source/format` is one of the known values, +/// - for quilt packages, the orig tarball exists next to the tree. +pub fn verify(tree: &Path) -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + // debian/rules: present + executable. + let rules_path = tree.join("debian/rules"); + let rules_mode = std::fs::metadata(&rules_path) + .map_err(|e| format!("'{}' is missing: {e}", rules_path.display()))? + .permissions() + .mode(); + if rules_mode & 0o111 == 0 { + return Err(format!( + "'{}' is not executable (pkh's build fixtures require the exec bit)", + rules_path.display() + ) + .into()); + } + + // debian/control: re-parse with the real parser. + let control_path = tree.join("debian/control"); + let control = ControlInfo::parse(&control_path) + .map_err(|e| format!("Generated '{}' does not parse: {e}", control_path.display()))?; + if control.binaries.is_empty() { + return Err(format!( + "Generated '{}' has no binary package stanza", + control_path.display() + ) + .into()); + } + + // debian/changelog: re-parse with the real parser. + let changelog_path = tree.join("debian/changelog"); + let (source, version, _distribution) = + crate::changelog::parse_changelog_header(&changelog_path).map_err(|e| { + format!( + "Generated '{}' does not parse: {e}", + changelog_path.display() + ) + })?; + let parsed_version = DebianVersion::parse(&version) + .map_err(|e| format!("Generated changelog version '{version}' is invalid: {e}"))?; + + // debian/source/format: one of the three known values. + let format_path = tree.join("debian/source/format"); + let format = std::fs::read_to_string(&format_path) + .map_err(|e| format!("'{}' is missing: {e}", format_path.display()))?; + let format = format.trim(); + if !super::debian::KNOWN_SOURCE_FORMATS.contains(&format) { + return Err(format!( + "'{}' carries the unknown source format '{format}'", + format_path.display() + ) + .into()); + } + + // Quilt packages need their orig tarball next to the tree. + if format == super::debian::SOURCE_FORMAT_QUILT { + let uversion = parsed_version.upstream; + let tarball = + super::debian::orig_tarball_path(tree, &source, &uversion).ok_or_else(|| { + format!( + "cannot determine the parent directory of '{}'", + tree.display() + ) + })?; + if !tarball.exists() { + return Err(format!( + "Quilt package without orig tarball: '{}' is missing. \ + Re-run pkh new, or pass --native.", + tarball.display() + ) + .into()); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::new::options::{License, NewOptions, SourceDir, TemplateId}; + use tempfile::tempdir; + + 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".into(), + long_description: "A tool".into(), + homepage: None, + license: License::Mit, + command: "mytool".into(), + maintainer: ("Jane".into(), "jane@example.com".into()), + dist: "debian".into(), + series: "sid".into(), + release: false, + depends: Vec::new(), + native: false, + git: false, + } + } + + /// Scaffold a complete tree with the given options (without git, so no + /// fixture below depends on the git binary). + fn scaffold_tree(dir: &Path, opts: &NewOptions) -> std::path::PathBuf { + let template = crate::new::templates::get(opts.template).unwrap(); + let tree = dir.join("tree"); + std::fs::create_dir_all(&tree).unwrap(); + + let mut files = crate::new::debian::files(opts, template); + files.extend(template.skeleton(opts)); + files.extend(template.debian(opts)); + crate::new::debian::write_files(&tree, &files).unwrap(); + if !opts.native { + crate::new::debian::create_orig_tarball(&tree, &opts.name, &opts.upstream_version) + .unwrap(); + } + tree + } + + #[test] + fn verify_accepts_a_good_tree() { + let dir = tempdir().unwrap(); + let tree = scaffold_tree(dir.path(), &opts()); + verify(&tree).unwrap(); + } + + #[test] + fn verify_accepts_native_trees_without_tarball() { + let dir = tempdir().unwrap(); + let tree = scaffold_tree( + dir.path(), + &NewOptions { + native: true, + ..opts() + }, + ); + verify(&tree).unwrap(); + } + + #[test] + fn verify_names_the_broken_file() { + // Each case needs its own tempdir: scaffolding refuses to overwrite + // an existing orig tarball. + let o = opts(); + + // Missing rules. + let dir = tempdir().unwrap(); + let tree = scaffold_tree(dir.path(), &o); + std::fs::remove_file(tree.join("debian/rules")).unwrap(); + let err = verify(&tree).unwrap_err().to_string(); + assert!(err.contains("debian/rules"), "{err}"); + + // Non-executable rules. + let dir = tempdir().unwrap(); + let tree = scaffold_tree(dir.path(), &o); + let mut perms = std::fs::metadata(tree.join("debian/rules")) + .unwrap() + .permissions(); + use std::os::unix::fs::PermissionsExt; + perms.set_mode(0o644); + std::fs::set_permissions(tree.join("debian/rules"), perms).unwrap(); + let err = verify(&tree).unwrap_err().to_string(); + assert!(err.contains("not executable"), "{err}"); + + // Broken control. + let dir = tempdir().unwrap(); + let tree = scaffold_tree(dir.path(), &o); + std::fs::write(tree.join("debian/control"), "not: a\ncontrol\n").unwrap(); + let err = verify(&tree).unwrap_err().to_string(); + assert!(err.contains("debian/control"), "{err}"); + + // Broken changelog. + let dir = tempdir().unwrap(); + let tree = scaffold_tree(dir.path(), &o); + std::fs::write(tree.join("debian/changelog"), "garbage\n").unwrap(); + let err = verify(&tree).unwrap_err().to_string(); + assert!(err.contains("debian/changelog"), "{err}"); + + // Unknown source format. + let dir = tempdir().unwrap(); + let tree = scaffold_tree(dir.path(), &o); + std::fs::write(tree.join("debian/source/format"), "42.0 (quilt)\n").unwrap(); + let err = verify(&tree).unwrap_err().to_string(); + assert!(err.contains("debian/source/format"), "{err}"); + + // Missing orig tarball. + let dir = tempdir().unwrap(); + let tree = scaffold_tree(dir.path(), &o); + std::fs::remove_file(dir.path().join("mytool_0.1.0.orig.tar.xz")).unwrap(); + let err = verify(&tree).unwrap_err().to_string(); + assert!(err.contains("orig tarball"), "{err}"); + } +}