Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9228ff448b | ||
|
|
9b98f5c7c3 | ||
|
|
d044f757e9 | ||
|
|
9c3394750d | ||
|
|
60976d3feb | ||
|
|
ae420989f9 |
+7
-1
@@ -259,7 +259,13 @@ fn prepend_to_file(path: &Path, content: &str) -> Result<(), Box<dyn std::error:
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_maintainer_info() -> Result<(String, String), Box<dyn std::error::Error>> {
|
/// 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<dyn std::error::Error>> {
|
||||||
// From environment variables
|
// From environment variables
|
||||||
if let (Ok(name), Ok(email)) = (std::env::var("DEBFULLNAME"), std::env::var("DEBEMAIL")) {
|
if let (Ok(name), Ok(email)) = (std::env::var("DEBFULLNAME"), std::env::var("DEBEMAIL")) {
|
||||||
return Ok((name, email));
|
return Ok((name, email));
|
||||||
|
|||||||
@@ -83,8 +83,20 @@ async fn build_binary_package_impl(
|
|||||||
let changelog_path = cwd.join("debian/changelog");
|
let changelog_path = cwd.join("debian/changelog");
|
||||||
let (package, version, package_series) =
|
let (package, version, package_series) =
|
||||||
crate::changelog::parse_changelog_header(&changelog_path)?;
|
crate::changelog::parse_changelog_header(&changelog_path)?;
|
||||||
|
// UNRELEASED is not a real archive series: without an explicit --series,
|
||||||
|
// build against the development series of the host vendor's distribution
|
||||||
|
// instead. An explicit --series always wins.
|
||||||
|
let resolved_series;
|
||||||
let series = if let Some(s) = series {
|
let series = if let Some(s) = series {
|
||||||
s
|
s
|
||||||
|
} else if crate::distro_info::is_unreleased(&package_series) {
|
||||||
|
let dist = crate::build::env::current_vendor();
|
||||||
|
resolved_series = crate::distro_info::effective_series(&package_series, &dist).await?;
|
||||||
|
log::info!(
|
||||||
|
"Changelog is UNRELEASED, building against series {}",
|
||||||
|
resolved_series
|
||||||
|
);
|
||||||
|
resolved_series.as_str()
|
||||||
} else {
|
} else {
|
||||||
&package_series
|
&package_series
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -169,6 +169,16 @@ pub fn supported_dists() -> Vec<String> {
|
|||||||
DATA.dist.keys().cloned().collect()
|
DATA.dist.keys().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Special changelog distribution marking an entry that has not been
|
||||||
|
/// released to any archive series yet
|
||||||
|
pub const UNRELEASED: &str = "UNRELEASED";
|
||||||
|
|
||||||
|
/// Whether `series` is the special [`UNRELEASED`] distribution rather than
|
||||||
|
/// a real archive series
|
||||||
|
pub fn is_unreleased(series: &str) -> bool {
|
||||||
|
series == UNRELEASED
|
||||||
|
}
|
||||||
|
|
||||||
/// Get time-ordered list of series information for a distribution, development series first
|
/// Get time-ordered list of series information for a distribution, development series first
|
||||||
pub async fn get_ordered_series(dist: &str) -> Result<Vec<SeriesInformation>, Box<dyn Error>> {
|
pub async fn get_ordered_series(dist: &str) -> Result<Vec<SeriesInformation>, Box<dyn Error>> {
|
||||||
let dist_data = DATA.dist.get(dist).ok_or_else(|| {
|
let dist_data = DATA.dist.get(dist).ok_or_else(|| {
|
||||||
@@ -206,6 +216,30 @@ pub async fn get_ordered_series_name(dist: &str) -> Result<Vec<String>, Box<dyn
|
|||||||
Ok(series.iter().map(|info| info.series.clone()).collect())
|
Ok(series.iter().map(|info| info.series.clone()).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The series to actually target when the changelog says [`UNRELEASED`]:
|
||||||
|
/// the development series of `dist`, i.e. the first entry of
|
||||||
|
/// [`get_ordered_series_name`] (which is documented "development series
|
||||||
|
/// first"). UNRELEASED work conventionally targets the next release, not
|
||||||
|
/// the last stable one. `dist` is matched case-insensitively, so vendor
|
||||||
|
/// names with original casing (dpkg's `Vendor:` field is e.g. "Ubuntu")
|
||||||
|
/// are accepted as-is. Any other `series` is returned unchanged. Errors
|
||||||
|
/// when `dist` is unknown or has no series list.
|
||||||
|
pub async fn effective_series(series: &str, dist: &str) -> Result<String, Box<dyn Error>> {
|
||||||
|
if !is_unreleased(series) {
|
||||||
|
return Ok(series.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The series data keys are lowercase, unlike the vendor names that
|
||||||
|
// callers typically resolve from dpkg
|
||||||
|
let dist = dist.to_lowercase();
|
||||||
|
|
||||||
|
get_ordered_series_name(&dist)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| format!("Distribution '{dist}' has no series to target").into())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the latest released series for a dist (excluding future releases and special cases like sid)
|
/// Get the latest released series for a dist (excluding future releases and special cases like sid)
|
||||||
pub async fn get_latest_released_series(dist: &str) -> Result<String, Box<dyn Error>> {
|
pub async fn get_latest_released_series(dist: &str) -> Result<String, Box<dyn Error>> {
|
||||||
let latest = get_n_latest_released_series(dist, 1).await?;
|
let latest = get_n_latest_released_series(dist, 1).await?;
|
||||||
@@ -523,6 +557,67 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_unreleased() {
|
||||||
|
// Matching is exact: UNRELEASED is uppercase by Debian convention
|
||||||
|
assert!(is_unreleased("UNRELEASED"));
|
||||||
|
assert!(!is_unreleased("unreleased"));
|
||||||
|
assert!(!is_unreleased("noble"));
|
||||||
|
assert!(!is_unreleased(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_effective_series_passthrough() {
|
||||||
|
// A real series is returned unchanged, and the dist is not even
|
||||||
|
// looked up (an unknown dist only matters for UNRELEASED)
|
||||||
|
assert_eq!(effective_series("noble", "ubuntu").await.unwrap(), "noble");
|
||||||
|
assert_eq!(effective_series("sid", "debian").await.unwrap(), "sid");
|
||||||
|
assert_eq!(
|
||||||
|
effective_series("noble", "unknown-distro").await.unwrap(),
|
||||||
|
"noble"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_effective_series_unreleased() {
|
||||||
|
// UNRELEASED resolves to the development series of the dist, i.e.
|
||||||
|
// the first entry of the time-ordered list. On current distro-info
|
||||||
|
// data this is the next Ubuntu release, while Debian's list starts
|
||||||
|
// with 'experimental' (sid comes second), so assert against the
|
||||||
|
// data itself rather than a hardcoded name.
|
||||||
|
for dist in ["ubuntu", "debian"] {
|
||||||
|
let ordered = get_ordered_series_name(dist).await.unwrap();
|
||||||
|
let resolved = effective_series(UNRELEASED, dist).await.unwrap();
|
||||||
|
assert_eq!(resolved, ordered[0]);
|
||||||
|
assert_ne!(resolved, UNRELEASED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_effective_series_unreleased_dist_case_insensitive() {
|
||||||
|
// Distro data keys are lowercase but dpkg vendors keep original
|
||||||
|
// casing ("Ubuntu"): the UNRELEASED lookup must resolve both
|
||||||
|
let expected = effective_series(UNRELEASED, "ubuntu").await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
effective_series(UNRELEASED, "Ubuntu").await.unwrap(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
effective_series(UNRELEASED, "UBUNTU").await.unwrap(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_effective_series_unknown_dist() {
|
||||||
|
// UNRELEASED on an unknown distribution cannot be resolved
|
||||||
|
assert!(
|
||||||
|
effective_series(UNRELEASED, "unknown-distro")
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_debian_series() {
|
async fn test_get_debian_series() {
|
||||||
let series = get_ordered_series_name("debian").await.unwrap();
|
let series = get_ordered_series_name("debian").await.unwrap();
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ pub mod deb;
|
|||||||
pub mod debian;
|
pub mod debian;
|
||||||
/// Obtain general information about distribution, series, etc
|
/// Obtain general information about distribution, series, etc
|
||||||
pub mod distro_info;
|
pub mod distro_info;
|
||||||
|
/// Scaffold a new Debian source package (`pkh new`)
|
||||||
|
pub mod new;
|
||||||
/// Obtain information about one or multiple packages
|
/// Obtain information about one or multiple packages
|
||||||
pub mod package_info;
|
pub mod package_info;
|
||||||
/// Prune residual pkh build artifacts and caches
|
/// Prune residual pkh build artifacts and caches
|
||||||
|
|||||||
+189
-5
@@ -35,6 +35,105 @@ fn main() {
|
|||||||
let matches = command!()
|
let matches = command!()
|
||||||
.subcommand_required(true)
|
.subcommand_required(true)
|
||||||
.disable_version_flag(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 ./<name>/ 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 ./<name>/"),
|
||||||
|
)
|
||||||
|
.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 <EMAIL>")
|
||||||
|
.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(
|
.subcommand(
|
||||||
Command::new("pull")
|
Command::new("pull")
|
||||||
.about("Pull a source package from the archive or git")
|
.about("Pull a source package from the archive or git")
|
||||||
@@ -134,6 +233,64 @@ fn main() {
|
|||||||
.get_matches();
|
.get_matches();
|
||||||
|
|
||||||
match matches.subcommand() {
|
match matches.subcommand() {
|
||||||
|
Some(("new", sub_matches)) => {
|
||||||
|
let depends: Vec<String> = sub_matches
|
||||||
|
.get_many::<String>("depends")
|
||||||
|
.map(|values| values.cloned().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let no_verify = sub_matches
|
||||||
|
.get_one::<bool>("no_verify")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false);
|
||||||
|
let cli = pkh::new::options::NewCli {
|
||||||
|
name: sub_matches.get_one::<String>("name").cloned(),
|
||||||
|
lang: sub_matches.get_one::<String>("lang").cloned(),
|
||||||
|
source: sub_matches
|
||||||
|
.get_one::<String>("source")
|
||||||
|
.map(std::path::PathBuf::from),
|
||||||
|
upstream_version: sub_matches.get_one::<String>("upstream_version").cloned(),
|
||||||
|
revision: sub_matches.get_one::<u32>("revision").copied(),
|
||||||
|
description: sub_matches.get_one::<String>("description").cloned(),
|
||||||
|
homepage: sub_matches.get_one::<String>("homepage").cloned(),
|
||||||
|
license: sub_matches.get_one::<String>("license").cloned(),
|
||||||
|
command: sub_matches.get_one::<String>("command").cloned(),
|
||||||
|
maintainer: sub_matches.get_one::<String>("maintainer").cloned(),
|
||||||
|
depends,
|
||||||
|
dist: sub_matches.get_one::<String>("dist").cloned(),
|
||||||
|
series: sub_matches.get_one::<String>("series").cloned(),
|
||||||
|
release: sub_matches
|
||||||
|
.get_one::<bool>("release")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false),
|
||||||
|
native: sub_matches
|
||||||
|
.get_one::<bool>("native")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false),
|
||||||
|
git: !sub_matches
|
||||||
|
.get_one::<bool>("no_git")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false),
|
||||||
|
defaults: sub_matches
|
||||||
|
.get_one::<bool>("defaults")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The wizard (interactive terminal) fills the same NewCli and
|
||||||
|
// resolves through the same pipeline; without a TTY the resolve
|
||||||
|
// error lists every missing answer. Afterwards the two
|
||||||
|
// verification builds are offered (`--no-verify` skips them;
|
||||||
|
// the structural self-checks inside `scaffold` always run).
|
||||||
|
if let Err(e) = rt.block_on(async {
|
||||||
|
let opts = pkh::new::questions::run(cli).await?;
|
||||||
|
pkh::new::scaffold(opts.clone(), &multi)?;
|
||||||
|
pkh::new::questions::offer_verification(&opts, &multi, no_verify).await;
|
||||||
|
Ok::<(), Box<dyn std::error::Error>>(())
|
||||||
|
}) {
|
||||||
|
error!("{}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(("pull", sub_matches)) => {
|
Some(("pull", sub_matches)) => {
|
||||||
let package = sub_matches.get_one::<String>("package").expect("required");
|
let package = sub_matches.get_one::<String>("package").expect("required");
|
||||||
let series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
|
let series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
|
||||||
@@ -198,14 +355,41 @@ fn main() {
|
|||||||
let changelog_path = cwd.join("debian/changelog");
|
let changelog_path = cwd.join("debian/changelog");
|
||||||
match pkh::changelog::parse_changelog_header(&changelog_path) {
|
match pkh::changelog::parse_changelog_header(&changelog_path) {
|
||||||
Ok((_pkg, _ver, current_series)) => {
|
Ok((_pkg, _ver, current_series)) => {
|
||||||
// Try to get the list of available series for this distribution
|
// UNRELEASED is not a real series: offer it as a
|
||||||
|
// pinned first entry (selecting it keeps the changelog
|
||||||
|
// unreleased) on top of the current vendor's series
|
||||||
|
// list, defaulting to the development series. Any
|
||||||
|
// other series resolves through the series list of
|
||||||
|
// its own distribution.
|
||||||
match rt.block_on(async {
|
match rt.block_on(async {
|
||||||
let dist =
|
if pkh::distro_info::is_unreleased(¤t_series) {
|
||||||
pkh::distro_info::get_dist_from_series(¤t_series).await?;
|
// Vendors keep original casing ("Ubuntu"),
|
||||||
pkh::distro_info::get_ordered_series_name(&dist).await
|
// while the series data keys are lowercase
|
||||||
|
let dist = pkh::build::env::current_vendor().to_lowercase();
|
||||||
|
let mut series_list =
|
||||||
|
vec![pkh::distro_info::UNRELEASED.to_string()];
|
||||||
|
series_list.extend(
|
||||||
|
pkh::distro_info::get_ordered_series_name(&dist).await?,
|
||||||
|
);
|
||||||
|
Ok(series_list)
|
||||||
|
} else {
|
||||||
|
let dist =
|
||||||
|
pkh::distro_info::get_dist_from_series(¤t_series).await?;
|
||||||
|
pkh::distro_info::get_ordered_series_name(&dist).await
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Ok(series_list) => {
|
Ok(series_list) => {
|
||||||
match pkh::ui::select_series(&series_list, ¤t_series) {
|
// Default to the development series (the
|
||||||
|
// first real entry) when the changelog is
|
||||||
|
// UNRELEASED, not to the pinned entry itself
|
||||||
|
let default = if pkh::distro_info::is_unreleased(¤t_series)
|
||||||
|
&& series_list.len() > 1
|
||||||
|
{
|
||||||
|
series_list[1].clone()
|
||||||
|
} else {
|
||||||
|
current_series.clone()
|
||||||
|
};
|
||||||
|
match pkh::ui::select_series(&series_list, &default) {
|
||||||
Ok(selected) => Some(selected),
|
Ok(selected) => Some(selected),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(
|
error!(
|
||||||
|
|||||||
@@ -0,0 +1,812 @@
|
|||||||
|
//! 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<std::path::PathBuf> {
|
||||||
|
tree.parent()
|
||||||
|
.map(|parent| parent.join(format!("{name}_{upstream_version}.orig.tar.xz")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render every common `debian/` file of the package.
|
||||||
|
pub fn files(opts: &NewOptions, template: &dyn Template) -> Vec<OutputFile> {
|
||||||
|
let mut files = vec![
|
||||||
|
source_format(opts),
|
||||||
|
changelog(opts),
|
||||||
|
control(opts, template),
|
||||||
|
rules(opts, template),
|
||||||
|
copyright(opts),
|
||||||
|
debian_gitignore(opts),
|
||||||
|
];
|
||||||
|
if !opts.native {
|
||||||
|
files.push(local_options());
|
||||||
|
}
|
||||||
|
if opts.autopkgtest {
|
||||||
|
files.push(autopkgtest_control());
|
||||||
|
files.push(autopkgtest_smoke(opts));
|
||||||
|
}
|
||||||
|
if let Some(watch) = &opts.watch {
|
||||||
|
files.push(OutputFile::new("debian/watch", watch.clone()));
|
||||||
|
}
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `debian/tests/control`: the autopkgtest smoke test definition.
|
||||||
|
fn autopkgtest_control() -> OutputFile {
|
||||||
|
OutputFile::new(
|
||||||
|
"debian/tests/control",
|
||||||
|
"Tests: smoke\nDepends: @\nRestrictions: allow-stderr\n",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `debian/tests/smoke`: run the installed command once; `--help` first,
|
||||||
|
/// `--version` as the fallback (some tools only answer one of them).
|
||||||
|
fn autopkgtest_smoke(opts: &NewOptions) -> OutputFile {
|
||||||
|
OutputFile::executable(
|
||||||
|
"debian/tests/smoke",
|
||||||
|
format!(
|
||||||
|
"#!/bin/sh\n\
|
||||||
|
set -e\n\
|
||||||
|
{command} --help >/dev/null 2>&1 || {command} --version\n",
|
||||||
|
command = opts.command,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `debian/source/format`: `3.0 (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));
|
||||||
|
|
||||||
|
for (key, value) in template.source_fields(opts) {
|
||||||
|
control.push_str(&format!("{key}: {value}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(homepage) = &opts.homepage {
|
||||||
|
control.push_str(&format!("Homepage: {homepage}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
control.push('\n');
|
||||||
|
|
||||||
|
// Binary stanza.
|
||||||
|
control.push_str(&format!("Package: {}\n", opts.name));
|
||||||
|
control.push_str(&format!("Architecture: {}\n", template.architecture(opts)));
|
||||||
|
if !opts.depends.is_empty() {
|
||||||
|
control.push_str(&render_field("Depends", &opts.depends));
|
||||||
|
}
|
||||||
|
control.push_str(&format!("Description: {}\n", opts.summary));
|
||||||
|
control.push_str(&render_continuation_text(&opts.long_description));
|
||||||
|
|
||||||
|
OutputFile::new("debian/control", control)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `debian/rules`: the shebang and `%:` target whose recipe is the
|
||||||
|
/// template's dh line (plus the template's extra overrides, when any),
|
||||||
|
/// written with the executable bit.
|
||||||
|
fn rules(opts: &NewOptions, template: &dyn Template) -> OutputFile {
|
||||||
|
let mut contents = format!("#!/usr/bin/make -f\n%:\n\t{}\n", template.rules_dh_line());
|
||||||
|
let extra = template.rules_extra(opts);
|
||||||
|
if !extra.is_empty() {
|
||||||
|
contents.push('\n');
|
||||||
|
contents.push_str(&extra);
|
||||||
|
if !contents.ends_with('\n') {
|
||||||
|
contents.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutputFile::executable("debian/rules", contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short license-reference paragraph embedded in `debian/copyright`.
|
||||||
|
fn license_reference_paragraph(license: &super::options::License) -> String {
|
||||||
|
use super::options::License;
|
||||||
|
match license {
|
||||||
|
License::Custom(s) if s.eq_ignore_ascii_case("unknown") => {
|
||||||
|
"The licensing terms of this package are not known yet. \
|
||||||
|
Replace this paragraph with a proper license reference."
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
License::Custom(s) => format!(
|
||||||
|
"The package is distributed under the terms of the '{s}' license. \
|
||||||
|
Replace this paragraph with the full license reference."
|
||||||
|
),
|
||||||
|
known => format!(
|
||||||
|
"The package is distributed under the terms of the {} license. \
|
||||||
|
The full license text is available at <{}>.",
|
||||||
|
known.spdx(),
|
||||||
|
known.spdx_url()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `debian/copyright` in the DEP-5 machine-readable format: header, the
|
||||||
|
/// `Files: *` stanza covering the current year, and a standalone license
|
||||||
|
/// stanza with a short reference paragraph.
|
||||||
|
fn copyright(opts: &NewOptions) -> OutputFile {
|
||||||
|
let year = chrono::Local::now().year();
|
||||||
|
let mut out = String::new();
|
||||||
|
|
||||||
|
out.push_str("Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n");
|
||||||
|
out.push_str(&format!("Upstream-Name: {}\n", opts.name));
|
||||||
|
if let Some(homepage) = &opts.homepage {
|
||||||
|
out.push_str(&format!("Source: {homepage}\n"));
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
|
||||||
|
out.push_str("Files: *\n");
|
||||||
|
out.push_str(&format!(
|
||||||
|
"Copyright: {} {} <{}>\n",
|
||||||
|
year, opts.maintainer.0, opts.maintainer.1
|
||||||
|
));
|
||||||
|
out.push_str(&format!("License: {}\n", opts.license.spdx()));
|
||||||
|
out.push_str(&render_continuation_text(&license_reference_paragraph(
|
||||||
|
&opts.license,
|
||||||
|
)));
|
||||||
|
out.push('\n');
|
||||||
|
|
||||||
|
out.push_str(&format!("License: {}\n", opts.license.spdx()));
|
||||||
|
out.push_str(&render_continuation_text(&license_reference_paragraph(
|
||||||
|
&opts.license,
|
||||||
|
)));
|
||||||
|
|
||||||
|
OutputFile::new("debian/copyright", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `debian/.gitignore`: the debhelper build artifacts.
|
||||||
|
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<String> {
|
||||||
|
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 `../<name>_<upstream_version>.orig.tar.xz` containing the tree,
|
||||||
|
/// excluding `debian/` and VCS/build directories, so the first
|
||||||
|
/// `dpkg-source -b` (quilt) succeeds immediately. Refuses to overwrite an
|
||||||
|
/// existing tarball.
|
||||||
|
pub fn create_orig_tarball(
|
||||||
|
tree: &Path,
|
||||||
|
name: &str,
|
||||||
|
upstream_version: &str,
|
||||||
|
) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
|
||||||
|
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<XzEncoder<std::fs::File>>,
|
||||||
|
dir: &Path,
|
||||||
|
archive_path: &str,
|
||||||
|
depth: usize,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(dir)?.collect::<Result<_, _>>()?;
|
||||||
|
entries.sort_by_key(|entry| entry.file_name());
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let path = entry.path();
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let name = file_name.to_string_lossy().into_owned();
|
||||||
|
|
||||||
|
if depth == 0 && name == "debian" {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
for file in files {
|
||||||
|
let path = tree.join(&file.path);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(&path, &file.contents)?;
|
||||||
|
if file.executable {
|
||||||
|
let mut permissions = std::fs::metadata(&path)?.permissions();
|
||||||
|
permissions.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(&path, permissions)?;
|
||||||
|
}
|
||||||
|
log::debug!("Wrote {}", path.display());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::new::options::{License, SourceDir, TemplateId};
|
||||||
|
|
||||||
|
fn opts() -> NewOptions {
|
||||||
|
NewOptions {
|
||||||
|
name: "mytool".into(),
|
||||||
|
template: TemplateId::Shell,
|
||||||
|
source_dir: SourceDir::Skeleton,
|
||||||
|
upstream_version: "0.1.0".into(),
|
||||||
|
revision: 1,
|
||||||
|
summary: "A tool that does one thing well".into(),
|
||||||
|
long_description: "A tool that does one thing well".into(),
|
||||||
|
homepage: Some("https://example.com/mytool".into()),
|
||||||
|
license: License::Mit,
|
||||||
|
command: "mytool".into(),
|
||||||
|
maintainer: ("Jane Doe".into(), "jane@example.com".into()),
|
||||||
|
dist: "ubuntu".into(),
|
||||||
|
series: "resolute".into(),
|
||||||
|
release: false,
|
||||||
|
depends: Vec::new(),
|
||||||
|
native: false,
|
||||||
|
git: true,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_format_and_local_options() {
|
||||||
|
let o = opts();
|
||||||
|
let files = super::files(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
|
||||||
|
let find = |path: &str| {
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == path)
|
||||||
|
.unwrap_or_else(|| panic!("{path} missing"))
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(find("debian/source/format").contents, "3.0 (quilt)\n");
|
||||||
|
assert_eq!(
|
||||||
|
find("debian/source/local-options").contents,
|
||||||
|
"single-debian-patch\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
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 <jane@example.com> ")
|
||||||
|
);
|
||||||
|
let date_line = changelog
|
||||||
|
.contents
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with(" -- "))
|
||||||
|
.unwrap();
|
||||||
|
let date = date_line.rsplit_once(" ").unwrap().1;
|
||||||
|
// `%d` is zero-padded: positions 5-6 must be the two-digit day
|
||||||
|
// (e.g. "Tue, 05 Sep 2026 ...").
|
||||||
|
assert!(date[5..7].bytes().all(|b| b.is_ascii_digit()));
|
||||||
|
|
||||||
|
// --release writes the target series.
|
||||||
|
let released = NewOptions {
|
||||||
|
release: true,
|
||||||
|
..opts()
|
||||||
|
};
|
||||||
|
std::fs::write(&path, super::changelog(&released).contents).unwrap();
|
||||||
|
let (_, _, distribution) = crate::changelog::parse_changelog_header(&path).unwrap();
|
||||||
|
assert_eq!(distribution, "resolute");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_rendering_and_parse() {
|
||||||
|
let o = opts();
|
||||||
|
let control = super::control(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
|
||||||
|
|
||||||
|
// RFC822 continuation: first dep on the field line, the rest indented.
|
||||||
|
assert!(
|
||||||
|
control
|
||||||
|
.contents
|
||||||
|
.contains("Build-Depends: debhelper-compat (= 13)\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
let parsed = crate::debian::ControlInfo::parse_content(&control.contents).unwrap();
|
||||||
|
assert_eq!(parsed.source_name(), "mytool");
|
||||||
|
assert_eq!(parsed.source.get("Section"), Some("utils"));
|
||||||
|
assert_eq!(parsed.source.get("Priority"), Some("optional"));
|
||||||
|
assert_eq!(parsed.source.get("Rules-Requires-Root"), Some("no"));
|
||||||
|
assert_eq!(
|
||||||
|
parsed.source.get("Homepage"),
|
||||||
|
Some("https://example.com/mytool")
|
||||||
|
);
|
||||||
|
assert_eq!(parsed.binaries.len(), 1);
|
||||||
|
assert_eq!(parsed.binaries[0].get("Package"), Some("mytool"));
|
||||||
|
assert_eq!(parsed.binaries[0].get("Architecture"), Some("all"));
|
||||||
|
assert_eq!(
|
||||||
|
parsed.binaries[0].get("Description"),
|
||||||
|
Some("A tool that does one thing well\nA tool that does one thing well")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Without homepage both the control Homepage field and the DEP-5
|
||||||
|
// Source field are absent (the stanza's leading `Source:` line is
|
||||||
|
// still there of course).
|
||||||
|
let o = NewOptions {
|
||||||
|
homepage: None,
|
||||||
|
..opts()
|
||||||
|
};
|
||||||
|
let control = super::control(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
|
||||||
|
assert!(!control.contents.contains("Homepage:"));
|
||||||
|
let parsed = crate::debian::ControlInfo::parse_content(&control.contents).unwrap();
|
||||||
|
assert!(parsed.source.get("Homepage").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rules_is_executable_minimal_makefile() {
|
||||||
|
let o = opts();
|
||||||
|
let rules = super::rules(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
|
||||||
|
assert!(rules.executable);
|
||||||
|
assert_eq!(rules.contents, "#!/usr/bin/make -f\n%:\n\tdh $@\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extra_files_autopkgtest_and_watch() {
|
||||||
|
let mut o = opts();
|
||||||
|
o.autopkgtest = true;
|
||||||
|
o.watch = Some(
|
||||||
|
"version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
let files = super::files(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
|
||||||
|
let find = |path: &str| {
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == path)
|
||||||
|
.unwrap_or_else(|| panic!("{path} missing"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let control = find("debian/tests/control");
|
||||||
|
assert_eq!(
|
||||||
|
control.contents,
|
||||||
|
"Tests: smoke\nDepends: @\nRestrictions: allow-stderr\n"
|
||||||
|
);
|
||||||
|
let smoke = find("debian/tests/smoke");
|
||||||
|
assert!(smoke.executable);
|
||||||
|
assert!(smoke.contents.starts_with("#!/bin/sh\nset -e\n"));
|
||||||
|
assert!(
|
||||||
|
smoke
|
||||||
|
.contents
|
||||||
|
.contains("mytool --help >/dev/null 2>&1 || mytool --version")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
find("debian/watch").contents,
|
||||||
|
"version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Without the extras none of the files are rendered.
|
||||||
|
let plain = super::files(
|
||||||
|
&opts(),
|
||||||
|
crate::new::templates::get(TemplateId::Shell).unwrap(),
|
||||||
|
);
|
||||||
|
assert!(!plain.iter().any(|f| f.path.starts_with("debian/tests")));
|
||||||
|
assert!(!plain.iter().any(|f| f.path == "debian/watch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copyright_is_dep5() {
|
||||||
|
let c = super::copyright(&opts());
|
||||||
|
assert!(c.contents.starts_with(
|
||||||
|
"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n"
|
||||||
|
));
|
||||||
|
assert!(c.contents.contains("Upstream-Name: mytool\n"));
|
||||||
|
assert!(c.contents.contains("Source: https://example.com/mytool\n"));
|
||||||
|
assert!(c.contents.contains("Files: *\n"));
|
||||||
|
assert!(c.contents.contains("License: MIT\n"));
|
||||||
|
assert!(c.contents.contains(&format!(
|
||||||
|
"Copyright: {} Jane Doe <jane@example.com>\n",
|
||||||
|
chrono::Local::now().year()
|
||||||
|
)));
|
||||||
|
assert!(c.contents.contains("https://spdx.org/licenses/MIT.html"));
|
||||||
|
|
||||||
|
// Unknown license: honest reference paragraph, still valid deb822.
|
||||||
|
let o = NewOptions {
|
||||||
|
license: License::Custom("unknown".into()),
|
||||||
|
..opts()
|
||||||
|
};
|
||||||
|
let c = super::copyright(&o);
|
||||||
|
assert!(c.contents.contains("not known yet"));
|
||||||
|
assert!(crate::debian::parse_paragraphs(&c.contents).len() >= 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debian_gitignore_contents() {
|
||||||
|
let g = super::debian_gitignore(&opts());
|
||||||
|
assert_eq!(
|
||||||
|
g.contents,
|
||||||
|
"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<String> = archive
|
||||||
|
.entries()
|
||||||
|
.unwrap()
|
||||||
|
.map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// The tree prefix and the nested file are there...
|
||||||
|
assert!(
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.any(|n| n.trim_end_matches('/') == "mytool-0.1.0")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.any(|n| n == "mytool-0.1.0/src/nested/code.txt")
|
||||||
|
);
|
||||||
|
// ...but debian/, target/ and other excluded names are not.
|
||||||
|
assert!(!names.iter().any(|n| n.contains("debian")));
|
||||||
|
assert!(!names.iter().any(|n| n.contains("target")));
|
||||||
|
names.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orig_tarball_refuses_overwrite() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let tree = dir.path().join("mytool");
|
||||||
|
std::fs::create_dir_all(&tree).unwrap();
|
||||||
|
std::fs::write(dir.path().join("mytool_0.1.0.orig.tar.xz"), b"existing").unwrap();
|
||||||
|
|
||||||
|
let err = create_orig_tarball(&tree, "mytool", "0.1.0").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("already exists"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
//! 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 regex::Regex;
|
||||||
|
|
||||||
|
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<TemplateId>),
|
||||||
|
/// 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<TemplateId> = 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.
|
||||||
|
pub fn single_script(dir: &Path) -> Option<std::path::PathBuf> {
|
||||||
|
let mut found: Option<std::path::PathBuf> = 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"#!")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// License files looked at by [`sniff_license`], in preference order.
|
||||||
|
const LICENSE_FILES: [&str; 5] = [
|
||||||
|
"LICENSE",
|
||||||
|
"LICENSE.md",
|
||||||
|
"LICENSE.txt",
|
||||||
|
"COPYING",
|
||||||
|
"COPYING.txt",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Sniff the license of the project in `dir` from its `LICENSE`/`COPYING`
|
||||||
|
/// file: an `SPDX-License-Identifier:` line wins, otherwise the text is
|
||||||
|
/// matched against a short list of recognizable licenses (MIT, BSD-2/3,
|
||||||
|
/// Apache-2.0, GPL-2/3, LGPL-2.1/3, ISC). `None` when no license file
|
||||||
|
/// exists or nothing recognizable is found.
|
||||||
|
pub fn sniff_license(dir: &Path) -> Option<String> {
|
||||||
|
let content = LICENSE_FILES
|
||||||
|
.iter()
|
||||||
|
.find_map(|name| std::fs::read_to_string(dir.join(name)).ok())
|
||||||
|
// Case variants and suffixes (LICENSE-MIT, LICENCE, cpYING…): the
|
||||||
|
// first top-level file whose name looks like a license notice.
|
||||||
|
.or_else(|| {
|
||||||
|
let mut candidates: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
|
||||||
|
.ok()?
|
||||||
|
.flatten()
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| {
|
||||||
|
path.is_file()
|
||||||
|
&& path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.is_some_and(|name| {
|
||||||
|
let name = name.to_ascii_uppercase();
|
||||||
|
// American and British spellings both count.
|
||||||
|
name.starts_with("LICENSE")
|
||||||
|
|| name.starts_with("LICENCE")
|
||||||
|
|| name.starts_with("COPYING")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
candidates.sort();
|
||||||
|
std::fs::read_to_string(candidates.into_iter().next()?).ok()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// An explicit SPDX identifier is the most reliable signal.
|
||||||
|
static SPDX_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||||
|
let spdx = SPDX_REGEX.get_or_init(|| {
|
||||||
|
Regex::new(r"(?i)SPDX-License-Identifier\s*:\s*([A-Za-z0-9+.\- ]+)").unwrap()
|
||||||
|
});
|
||||||
|
if let Some(id) = spdx
|
||||||
|
.captures(&content)
|
||||||
|
.and_then(|caps| caps.get(1))
|
||||||
|
.map(|id| id.as_str().trim_end().to_string())
|
||||||
|
.filter(|id| !id.is_empty())
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = content.to_ascii_lowercase();
|
||||||
|
if text.contains("apache license") && text.contains("version 2") {
|
||||||
|
return Some("Apache-2.0".to_string());
|
||||||
|
}
|
||||||
|
if text.contains("lesser general public license") {
|
||||||
|
return if text.contains("version 3") && !text.contains("version 2.1") {
|
||||||
|
Some("LGPL-3.0+".to_string())
|
||||||
|
} else {
|
||||||
|
Some("LGPL-2.1+".to_string())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if text.contains("general public license") {
|
||||||
|
return if text.contains("version 3") {
|
||||||
|
Some("GPL-3.0+".to_string())
|
||||||
|
} else {
|
||||||
|
Some("GPL-2.0+".to_string())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if text.contains("mit license") || text.contains("permission is hereby granted, free of charge")
|
||||||
|
{
|
||||||
|
return Some("MIT".to_string());
|
||||||
|
}
|
||||||
|
if text.contains("isc license")
|
||||||
|
|| text.contains("permission to use, copy, modify, and/or distribute this software")
|
||||||
|
{
|
||||||
|
return Some("ISC".to_string());
|
||||||
|
}
|
||||||
|
if text.contains("redistribution and use in source and binary forms") {
|
||||||
|
// The third clause (name endorsement) is what sets BSD-3 apart
|
||||||
|
// from BSD-2.
|
||||||
|
return if text.contains("endorse or promote") {
|
||||||
|
Some("BSD-3-Clause".to_string())
|
||||||
|
} else {
|
||||||
|
Some("BSD-2-Clause".to_string())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distinctive (shortened) excerpts of the recognizable license texts.
|
||||||
|
const LICENSE_TEXTS: [(&str, &str); 9] = [
|
||||||
|
(
|
||||||
|
"MIT",
|
||||||
|
"MIT License\n\nPermission is hereby granted, free of charge, to any person",
|
||||||
|
),
|
||||||
|
("Apache-2.0", "Apache License\nVersion 2.0, January 2004"),
|
||||||
|
(
|
||||||
|
"GPL-2.0+",
|
||||||
|
"GNU GENERAL PUBLIC LICENSE\nVersion 2, June 1991\neither version 2 of the License",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"GPL-3.0+",
|
||||||
|
"GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"LGPL-2.1+",
|
||||||
|
"GNU LESSER GENERAL PUBLIC LICENSE\nVersion 2.1, February 1999",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"LGPL-3.0+",
|
||||||
|
"GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"BSD-2-Clause",
|
||||||
|
"Redistribution and use in source and binary forms, with or without\nmodification, are permitted",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"Redistribution and use in source and binary forms, with or without\nmay be used to endorse or promote products",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ISC",
|
||||||
|
"ISC License\nPermission to use, copy, modify, and/or distribute this software",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sniff_license_recognizes_license_files() {
|
||||||
|
for (expected, text) in LICENSE_TEXTS {
|
||||||
|
// Every candidate file name is looked at.
|
||||||
|
for name in ["LICENSE", "COPYING", "LICENSE.md", "COPYING.txt"] {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join(name), text).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
sniff_license(dir.path()).as_deref(),
|
||||||
|
Some(expected),
|
||||||
|
"{name}: {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sniff_license_prefers_spdx_identifier() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("LICENSE"),
|
||||||
|
"Custom terms here\nSPDX-License-Identifier: Zlib\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(sniff_license(dir.path()).as_deref(), Some("Zlib"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sniff_license_handles_case_variants_and_missing_files() {
|
||||||
|
// Unusual spelling found through the directory scan.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("Licence.TXT"),
|
||||||
|
"Permission is hereby granted, free of charge",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(sniff_license(dir.path()).as_deref(), Some("MIT"));
|
||||||
|
|
||||||
|
// Exact candidates win over the directory scan (LICENSE before
|
||||||
|
// LICENSE.blurb).
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("LICENSE.blurb"),
|
||||||
|
"GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(dir.path().join("LICENSE"), "MIT License").unwrap();
|
||||||
|
assert_eq!(sniff_license(dir.path()).as_deref(), Some("MIT"));
|
||||||
|
|
||||||
|
// Unrecognizable or missing text: silent None.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("LICENSE"), "do whatever you want\n").unwrap();
|
||||||
|
assert_eq!(sniff_license(dir.path()), None);
|
||||||
|
assert_eq!(sniff_license(&dir.path().join("missing")), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<bool, Box<dyn std::error::Error>> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+716
@@ -0,0 +1,716 @@
|
|||||||
|
//! `pkh new`: interactive-first package scaffolding (see
|
||||||
|
//! `plans/pkh-new.md`).
|
||||||
|
//!
|
||||||
|
//! This module orchestrates a scaffold run: target directory checks, project
|
||||||
|
//! detection, in-memory rendering of every file (all-or-nothing write), the
|
||||||
|
//! template post-write hook (e.g. `cargo vendor`), orig tarball creation,
|
||||||
|
//! git initialization, structural verification and the next-steps message.
|
||||||
|
//! The interactive wizard ([`questions`]) fills a [`options::NewCli`] from
|
||||||
|
//! its answers on a TTY and reuses [`options::resolve`] as the single source
|
||||||
|
//! of truth for defaults and validation; without a TTY the same resolution
|
||||||
|
//! runs flag-driven.
|
||||||
|
|
||||||
|
pub mod debian;
|
||||||
|
pub mod detect;
|
||||||
|
pub mod git;
|
||||||
|
pub mod options;
|
||||||
|
pub mod questions;
|
||||||
|
pub mod templates;
|
||||||
|
pub mod verify;
|
||||||
|
|
||||||
|
use std::error::Error;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
|
|
||||||
|
use options::NewOptions;
|
||||||
|
use templates::OutputFile;
|
||||||
|
|
||||||
|
/// Scaffold a full Debian source tree from `opts`.
|
||||||
|
///
|
||||||
|
/// Steps, aborting early with a pointed error message:
|
||||||
|
/// 1. resolve the template from the registry,
|
||||||
|
/// 2. check the target directory (refuse an existing `debian/control`),
|
||||||
|
/// 3. render every file in memory and check for collisions,
|
||||||
|
/// 4. write the files all-or-nothing (plus the root `.gitignore` in skeleton
|
||||||
|
/// mode, appending to an existing one),
|
||||||
|
/// 5. run the template post-write hook (e.g. `cargo vendor`, so the vendored
|
||||||
|
/// sources land inside the orig tarball created next),
|
||||||
|
/// 6. create the orig tarball (quilt only, refusing overwrites),
|
||||||
|
/// 7. `git init` unless `--no-git` or already inside a repository,
|
||||||
|
/// 8. run the structural verification,
|
||||||
|
/// 9. print the success message with the next steps.
|
||||||
|
pub fn scaffold(opts: NewOptions, multi: &MultiProgress) -> Result<(), Box<dyn Error>> {
|
||||||
|
let pb = multi.add(ProgressBar::new_spinner());
|
||||||
|
pb.enable_steady_tick(Duration::from_millis(50));
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::default_bar()
|
||||||
|
.template("> {spinner:.blue} {prefix}")
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
pb.set_prefix("Scaffolding");
|
||||||
|
|
||||||
|
let result = scaffold_steps(&opts, &pb);
|
||||||
|
|
||||||
|
// Clear the spinner whatever the outcome; errors are reported by the
|
||||||
|
// caller as plain log lines.
|
||||||
|
pb.finish_and_clear();
|
||||||
|
multi.remove(&pb);
|
||||||
|
|
||||||
|
if 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<dyn Error>> {
|
||||||
|
// 1. Template resolution: an id without a registered template fails
|
||||||
|
// here with the friendly message instead of a parse error.
|
||||||
|
let template = templates::get(opts.template).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"The '{}' template has no registered implementation. \
|
||||||
|
This is a pkh bug; please report it.",
|
||||||
|
opts.template,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 2. Target directory checks.
|
||||||
|
let cwd = std::env::current_dir()?;
|
||||||
|
let target = opts.target_dir(&cwd);
|
||||||
|
if target.join("debian/control").exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"'{}' already contains a debian/control file: pkh new refuses to \
|
||||||
|
touch an existing Debian packaging tree",
|
||||||
|
target.display()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
match &opts.source_dir {
|
||||||
|
options::SourceDir::Skeleton => {
|
||||||
|
if target.exists() {
|
||||||
|
if !target.is_dir() {
|
||||||
|
return Err(
|
||||||
|
format!("'{}' exists and is not a directory", target.display()).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if std::fs::read_dir(&target)?.next().is_some() {
|
||||||
|
return Err(format!(
|
||||||
|
"directory '{}' already exists and is not empty: \
|
||||||
|
pkh new refuses to scaffold into it",
|
||||||
|
target.display()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options::SourceDir::Here => {
|
||||||
|
// The cwd always exists.
|
||||||
|
}
|
||||||
|
options::SourceDir::Path(path) => {
|
||||||
|
if !path.is_dir() {
|
||||||
|
return Err(format!(
|
||||||
|
"source directory '{}' does not exist or is not a directory",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail before writing anything when the orig tarball already exists.
|
||||||
|
if !opts.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<OutputFile> = debian::files(opts, template);
|
||||||
|
if skeleton {
|
||||||
|
files.extend(template.skeleton(opts));
|
||||||
|
}
|
||||||
|
files.extend(template.debian(opts));
|
||||||
|
|
||||||
|
let paths: Vec<String> = files.iter().map(|f| f.path.clone()).collect();
|
||||||
|
options::check_file_collisions(&paths)?;
|
||||||
|
for path in &paths {
|
||||||
|
let existing = target.join(path);
|
||||||
|
if existing.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"refusing to overwrite existing file '{}'",
|
||||||
|
existing.display()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Write the files (all-or-nothing: nothing was written on any error
|
||||||
|
// above).
|
||||||
|
pb.set_message("Writing files");
|
||||||
|
debian::write_files(&target, &files)?;
|
||||||
|
|
||||||
|
// Root .gitignore: skeleton mode only, never overwriting an existing
|
||||||
|
// file (append the missing entries instead).
|
||||||
|
if skeleton
|
||||||
|
&& let Some(contents) = debian::merge_root_gitignore(
|
||||||
|
std::fs::read_to_string(target.join(".gitignore"))
|
||||||
|
.ok()
|
||||||
|
.as_deref(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
std::fs::write(target.join(".gitignore"), contents)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Template post-write hook: run before the orig tarball is created,
|
||||||
|
// so files added here (rust: vendor/ + .cargo/config.toml) land
|
||||||
|
// inside it.
|
||||||
|
pb.set_message("Running template hooks");
|
||||||
|
template.post_write(opts, &target)?;
|
||||||
|
|
||||||
|
// 6. Orig tarball (quilt only).
|
||||||
|
if !opts.native {
|
||||||
|
pb.set_message("Creating orig tarball");
|
||||||
|
debian::create_orig_tarball(&target, &opts.name, &opts.upstream_version_no_epoch())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Git.
|
||||||
|
pb.set_message("Initializing git");
|
||||||
|
git::ensure_repository(&target, opts.git)?;
|
||||||
|
|
||||||
|
// 8. Structural verification.
|
||||||
|
pb.set_message("Verifying");
|
||||||
|
verify::verify(&target)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The success message: what was created and the next steps.
|
||||||
|
fn print_success(opts: &NewOptions) {
|
||||||
|
let target =
|
||||||
|
opts.target_dir(&std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")));
|
||||||
|
// `display_path` yields an empty string when the target is the cwd
|
||||||
|
// itself (Here mode): show it as `.`.
|
||||||
|
let display = match crate::ui::display_path(&target) {
|
||||||
|
display if display.is_empty() => ".".to_string(),
|
||||||
|
display => display,
|
||||||
|
};
|
||||||
|
log::info!(
|
||||||
|
"Created {display} — {} ({}-{}) for {}/{}, template '{}'",
|
||||||
|
opts.name,
|
||||||
|
opts.upstream_version,
|
||||||
|
opts.revision,
|
||||||
|
opts.dist,
|
||||||
|
opts.series,
|
||||||
|
opts.template
|
||||||
|
);
|
||||||
|
log::info!("Next steps:");
|
||||||
|
log::info!(" cd {display}");
|
||||||
|
if opts.release {
|
||||||
|
log::info!(
|
||||||
|
" pkh chlog # for later changes; the entry already targets {}",
|
||||||
|
opts.series
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
" pkh chlog # releases the UNRELEASED entry to '{}' when ready",
|
||||||
|
opts.series
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::new::options::{License, SourceDir, TemplateId};
|
||||||
|
use serial_test::serial;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn opts(template: TemplateId, name: &str, source_dir: SourceDir) -> NewOptions {
|
||||||
|
NewOptions {
|
||||||
|
name: name.to_string(),
|
||||||
|
template,
|
||||||
|
source_dir,
|
||||||
|
upstream_version: "0.1.0".into(),
|
||||||
|
revision: 1,
|
||||||
|
summary: "A tool that does one thing well".into(),
|
||||||
|
long_description: "A tool that does one thing well".into(),
|
||||||
|
homepage: None,
|
||||||
|
license: License::Mit,
|
||||||
|
command: name.to_string(),
|
||||||
|
maintainer: ("Jane Doe".into(), "jane@example.com".into()),
|
||||||
|
dist: "ubuntu".into(),
|
||||||
|
series: "resolute".into(),
|
||||||
|
release: false,
|
||||||
|
depends: Vec::new(),
|
||||||
|
native: false,
|
||||||
|
git: false,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `scaffold` with the cwd changed to `dir` (restored afterwards);
|
||||||
|
/// must run under `#[serial]` because the cwd is process-global.
|
||||||
|
fn scaffold_in(dir: &std::path::Path, opts: NewOptions) -> Result<(), Box<dyn Error>> {
|
||||||
|
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<String> = archive
|
||||||
|
.entries()
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| {
|
||||||
|
entry
|
||||||
|
.unwrap()
|
||||||
|
.path()
|
||||||
|
.unwrap()
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n == "mytool-0.1.0/mytool.sh"),
|
||||||
|
"{names:?}"
|
||||||
|
);
|
||||||
|
assert!(!names.iter().any(|n| n.contains("debian")), "{names:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn scaffold_empty_base_and_metapackage_flavors() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
// Metapackage flavor: non-empty depends.
|
||||||
|
let mut o = opts(TemplateId::Empty, "metapkg", SourceDir::Skeleton);
|
||||||
|
o.depends = vec!["hello".into(), "hello-data (>= 1.0)".into()];
|
||||||
|
scaffold_in(dir.path(), o).unwrap();
|
||||||
|
|
||||||
|
let tree = dir.path().join("metapkg");
|
||||||
|
let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
control.binaries[0].get("Depends"),
|
||||||
|
Some("hello,\nhello-data (>= 1.0)")
|
||||||
|
);
|
||||||
|
assert_eq!(control.binaries[0].get("Architecture"), Some("all"));
|
||||||
|
// No install file, no build-system skeleton: the README stub only.
|
||||||
|
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<String> = 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<String> = archive
|
||||||
|
.entries()
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| {
|
||||||
|
entry
|
||||||
|
.unwrap()
|
||||||
|
.path()
|
||||||
|
.unwrap()
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n == "runtool-0.1.0/run.sh"),
|
||||||
|
"{names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn scaffold_refuses_existing_trees_and_artifacts() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
|
||||||
|
// Existing debian/control.
|
||||||
|
let tree = dir.path().join("mytool");
|
||||||
|
std::fs::create_dir_all(tree.join("debian")).unwrap();
|
||||||
|
std::fs::write(tree.join("debian/control"), "Source: mytool\n").unwrap();
|
||||||
|
let err = scaffold_in(
|
||||||
|
dir.path(),
|
||||||
|
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("debian/control"), "{err}");
|
||||||
|
|
||||||
|
// Non-empty skeleton target.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("mytool")).unwrap();
|
||||||
|
std::fs::write(dir.path().join("mytool/junk"), "x").unwrap();
|
||||||
|
let err = scaffold_in(
|
||||||
|
dir.path(),
|
||||||
|
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("not empty"), "{err}");
|
||||||
|
|
||||||
|
// Existing orig tarball: nothing gets written.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("mytool_0.1.0.orig.tar.xz"), b"old").unwrap();
|
||||||
|
let err = scaffold_in(
|
||||||
|
dir.path(),
|
||||||
|
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("already exists"), "{err}");
|
||||||
|
assert!(!dir.path().join("mytool/debian/control").exists());
|
||||||
|
|
||||||
|
// Missing --source directory.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let err = scaffold_in(
|
||||||
|
dir.path(),
|
||||||
|
opts(
|
||||||
|
TemplateId::Shell,
|
||||||
|
"mytool",
|
||||||
|
SourceDir::Path(dir.path().join("missing")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("does not exist"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn scaffold_native_skips_tarball_and_local_options() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut o = opts(TemplateId::Shell, "nativepkg", SourceDir::Skeleton);
|
||||||
|
o.native = true;
|
||||||
|
scaffold_in(dir.path(), o).unwrap();
|
||||||
|
|
||||||
|
let tree = dir.path().join("nativepkg");
|
||||||
|
assert!(!dir.path().join("nativepkg_0.1.0.orig.tar.xz").exists());
|
||||||
|
assert!(!tree.join("debian/source/local-options").exists());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(tree.join("debian/source/format")).unwrap(),
|
||||||
|
"3.0 (native)\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end rust skeleton: the vendoring hook runs before the orig
|
||||||
|
/// tarball is created, so `.cargo/` (and `vendor/` when dependencies
|
||||||
|
/// exist) travel inside it. The vendoring step needs host cargo; on a
|
||||||
|
/// cargo-less host the scaffold still succeeds with a warning.
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn scaffold_rust_skeleton_vendors_before_tarball() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
scaffold_in(
|
||||||
|
dir.path(),
|
||||||
|
opts(TemplateId::Rust, "mytool", SourceDir::Skeleton),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tree = dir.path().join("mytool");
|
||||||
|
assert!(tree.join("Cargo.toml").exists());
|
||||||
|
assert!(tree.join("src/main.rs").exists());
|
||||||
|
|
||||||
|
// rules: the vendored build overrides, no --locked on a fresh
|
||||||
|
// skeleton without Cargo.lock.
|
||||||
|
let rules = std::fs::read_to_string(tree.join("debian/rules")).unwrap();
|
||||||
|
assert!(rules.contains("%:\n\tdh $@\n"));
|
||||||
|
assert!(rules.contains("override_dh_auto_build:\n\tcargo build --release --offline\n"));
|
||||||
|
assert!(rules.contains("override_dh_auto_install:\n\tinstall -Dm755 target/release/mytool debian/mytool/usr/bin/mytool"));
|
||||||
|
assert!(!rules.contains("--locked"));
|
||||||
|
|
||||||
|
// control: Architecture any + the cargo/rustc build-deps.
|
||||||
|
let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap();
|
||||||
|
assert_eq!(control.binaries[0].get("Architecture"), Some("any"));
|
||||||
|
assert_eq!(
|
||||||
|
control.source.get("Build-Depends"),
|
||||||
|
Some("debhelper-compat (= 13),\ncargo:native,\nrustc:native")
|
||||||
|
);
|
||||||
|
|
||||||
|
// The offline config exists when host cargo vendored the skeleton,
|
||||||
|
// and both it and the skeleton land inside the orig tarball.
|
||||||
|
let has_cargo = crate::new::templates::find_on_path("cargo").is_some();
|
||||||
|
if has_cargo {
|
||||||
|
let config = std::fs::read_to_string(tree.join(".cargo/config.toml")).unwrap();
|
||||||
|
assert!(config.contains("[source.crates-io]"), "{config}");
|
||||||
|
assert!(config.contains("[net]\noffline = true"), "{config}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz");
|
||||||
|
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
|
||||||
|
std::fs::File::open(&tarball).unwrap(),
|
||||||
|
));
|
||||||
|
let names: Vec<String> = archive
|
||||||
|
.entries()
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| {
|
||||||
|
entry
|
||||||
|
.unwrap()
|
||||||
|
.path()
|
||||||
|
.unwrap()
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n == "mytool-0.1.0/Cargo.toml"),
|
||||||
|
"{names:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n == "mytool-0.1.0/src/main.rs"),
|
||||||
|
"{names:?}"
|
||||||
|
);
|
||||||
|
if has_cargo {
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n == "mytool-0.1.0/.cargo/config.toml"),
|
||||||
|
"{names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(!names.iter().any(|n| n.contains("debian")), "{names:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end python skeleton: pyproject-based Build-Depends and the
|
||||||
|
/// module skeleton inside the orig tarball.
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn scaffold_python_skeleton_tree() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
scaffold_in(
|
||||||
|
dir.path(),
|
||||||
|
opts(TemplateId::Python, "mytool", SourceDir::Skeleton),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tree = dir.path().join("mytool");
|
||||||
|
let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap();
|
||||||
|
assert_eq!(control.binaries[0].get("Architecture"), Some("all"));
|
||||||
|
assert_eq!(
|
||||||
|
control.source.get("Build-Depends"),
|
||||||
|
Some(
|
||||||
|
"debhelper-compat (= 13),\ndh-python,\npython3-all,\n\
|
||||||
|
pybuild-plugin-pyproject,\npython3-setuptools"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let rules = std::fs::read_to_string(tree.join("debian/rules")).unwrap();
|
||||||
|
assert!(rules.contains("%:\n\tdh $@ --with python3 --buildsystem=pybuild\n"));
|
||||||
|
|
||||||
|
let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz");
|
||||||
|
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
|
||||||
|
std::fs::File::open(&tarball).unwrap(),
|
||||||
|
));
|
||||||
|
let names: Vec<String> = archive
|
||||||
|
.entries()
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| {
|
||||||
|
entry
|
||||||
|
.unwrap()
|
||||||
|
.path()
|
||||||
|
.unwrap()
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n == "mytool-0.1.0/pyproject.toml"),
|
||||||
|
"{names:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n == "mytool-0.1.0/mytool/__init__.py"),
|
||||||
|
"{names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end: the scaffolded shell tree passes the real source build
|
||||||
|
/// (`dpkg-source` and friends, same prerequisites as the differential
|
||||||
|
/// tests).
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn scaffold_then_source_build_produces_artifacts() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
scaffold_in(
|
||||||
|
dir.path(),
|
||||||
|
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = crate::build::run_source_build(
|
||||||
|
&dir.path().join("mytool"),
|
||||||
|
&crate::build::SourceBuildOptions::default(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(output.dsc.exists(), "{:?} missing", output.dsc);
|
||||||
|
assert!(output.buildinfo.exists(), "{:?} missing", output.buildinfo);
|
||||||
|
assert!(output.changes.exists(), "{:?} missing", output.changes);
|
||||||
|
// 3.0 (quilt): the orig tarball plus the debian diff tarball that
|
||||||
|
// dpkg-source generates for the debian/ directory.
|
||||||
|
assert_eq!(output.tarballs.len(), 2, "{:?}", output.tarballs);
|
||||||
|
assert!(output.tarballs[0].exists());
|
||||||
|
assert!(output.tarballs[1].exists());
|
||||||
|
// UNRELEASED: nothing is signed.
|
||||||
|
assert!(!output.signed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,982 @@
|
|||||||
|
//! 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,
|
||||||
|
/// and every variant has a template implementation registered in
|
||||||
|
/// [`crate::new::templates`].
|
||||||
|
#[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, String> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable menu label of this template, as offered by the wizard
|
||||||
|
/// language question (and reused in the summary screen).
|
||||||
|
pub fn display_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TemplateId::Rust => "Rust (Cargo.toml)",
|
||||||
|
TemplateId::Python => "Python (pyproject.toml / setup.py)",
|
||||||
|
TemplateId::Meson => "C/C++ (Meson)",
|
||||||
|
TemplateId::Cmake => "C/C++ (CMake)",
|
||||||
|
TemplateId::Autotools => "C/C++ (Autotools)",
|
||||||
|
TemplateId::Go => "Go module",
|
||||||
|
TemplateId::Shell => "Shell script / single interpreted file",
|
||||||
|
TemplateId::Makefile => "Generic (Makefile)",
|
||||||
|
TemplateId::Empty => "Metapackage / empty base (no build system)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The template whose menu label (or CLI identifier) is `label`.
|
||||||
|
pub fn from_label(label: &str) -> Option<TemplateId> {
|
||||||
|
TemplateId::all()
|
||||||
|
.into_iter()
|
||||||
|
.find(|id| id.display_name() == label || id.as_str() == label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `./<name>/`.
|
||||||
|
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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// Use the `3.0 (native)` source format (no orig tarball).
|
||||||
|
pub native: bool,
|
||||||
|
/// Initialize a git repository (gitignores are written regardless).
|
||||||
|
pub git: bool,
|
||||||
|
/// Write the autopkgtest smoke test (`debian/tests/control` +
|
||||||
|
/// `debian/tests/smoke`) running `<command> --help`/`--version`.
|
||||||
|
/// Wizard-only extra, off by default.
|
||||||
|
pub autopkgtest: bool,
|
||||||
|
/// Add `pkg-config` to Build-Depends (the meson/cmake opt-in question;
|
||||||
|
/// most such projects resolve their dependencies through it).
|
||||||
|
/// Wizard-only extra, off by default.
|
||||||
|
pub pkg_config: bool,
|
||||||
|
/// Contents of a `debian/watch` release watcher (GitHub/GitLab tarball
|
||||||
|
/// template). Wizard-only extra, off by default.
|
||||||
|
pub watch: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewOptions {
|
||||||
|
/// Directory the package tree lives in: `./<name>/` 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: `<upstream>-<revision>`.
|
||||||
|
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 `<name>`.
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// `--lang` value (parsed by [`resolve`]).
|
||||||
|
pub lang: Option<String>,
|
||||||
|
/// `--source <path>`.
|
||||||
|
pub source: Option<PathBuf>,
|
||||||
|
/// `--upstream-version <v>`.
|
||||||
|
pub upstream_version: Option<String>,
|
||||||
|
/// `--revision <n>`.
|
||||||
|
pub revision: Option<u32>,
|
||||||
|
/// `--description <one-liner>`.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// `--homepage <url>`.
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
/// `--license <SPDX>`.
|
||||||
|
pub license: Option<String>,
|
||||||
|
/// `--command <cmd>`.
|
||||||
|
pub command: Option<String>,
|
||||||
|
/// `--maintainer "Name <email>"`.
|
||||||
|
pub maintainer: Option<String>,
|
||||||
|
/// `--depends` values; each entry may itself be a comma-separated list.
|
||||||
|
pub depends: Vec<String>,
|
||||||
|
/// `--dist <dist>`.
|
||||||
|
pub dist: Option<String>,
|
||||||
|
/// `--series <s>`.
|
||||||
|
pub series: Option<String>,
|
||||||
|
/// `--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<Regex> = 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<String> {
|
||||||
|
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 <email>` 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<Regex> = 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 <email>'")
|
||||||
|
})?;
|
||||||
|
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<Vec<String>, 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::<Vec<_>>()
|
||||||
|
.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<NewOptions, String> {
|
||||||
|
let mut missing: Vec<String> = 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::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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 <id> (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 <one-liner>".to_string());
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !missing.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"Missing required answers (use --defaults to take every default, \
|
||||||
|
or re-run pkh new on an interactive terminal to answer the \
|
||||||
|
wizard):\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 <email>\"."
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
// Every id resolves from its menu label too, and labels are
|
||||||
|
// unique.
|
||||||
|
assert_eq!(TemplateId::from_label(id.display_name()), Some(id));
|
||||||
|
}
|
||||||
|
assert!(TemplateId::parse("cobol").is_err());
|
||||||
|
assert_eq!(
|
||||||
|
TemplateId::from_label("Rust (Cargo.toml)"),
|
||||||
|
Some(TemplateId::Rust)
|
||||||
|
);
|
||||||
|
assert_eq!(TemplateId::from_label("rust"), Some(TemplateId::Rust));
|
||||||
|
assert_eq!(TemplateId::from_label("nope"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
};
|
||||||
|
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 <jane@example.com>").unwrap(),
|
||||||
|
("Jane Doe".to_string(), "jane@example.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_maintainer(" X <x@y.z> ").unwrap(),
|
||||||
|
("X".to_string(), "x@y.z".to_string())
|
||||||
|
);
|
||||||
|
// Bad forms.
|
||||||
|
assert!(parse_maintainer("Jane Doe").is_err()); // no email
|
||||||
|
assert!(parse_maintainer("<jane@example.com>").is_err()); // no name
|
||||||
|
assert!(parse_maintainer("Jane <jane example.com>").is_err()); // no @
|
||||||
|
assert!(parse_maintainer("Jane <a@b@c>").is_err()); // two @s
|
||||||
|
assert!(parse_maintainer("Jane <jane@ example.com>").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 <jane@example.com>".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 <jane@example.com>".into()),
|
||||||
|
git: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = resolve(cli).await.unwrap_err();
|
||||||
|
assert!(err.contains("--release requires --series"), "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,977 @@
|
|||||||
|
//! The `pkh new` interactive wizard.
|
||||||
|
//!
|
||||||
|
//! [`run`] is the single entry point: on an interactive terminal it asks the
|
||||||
|
//! questions of the spec's "Proposed UX" transcript, fills a
|
||||||
|
//! [`NewCli`] with the answers (explicit flags are never re-asked), and
|
||||||
|
//! reuses [`options::resolve`] as the single source of truth for defaults,
|
||||||
|
//! detection and validation — so the non-interactive and interactive paths
|
||||||
|
//! cannot drift apart. Without a terminal (or with `--defaults`) it goes
|
||||||
|
//! straight through [`options::resolve`], whose error lists every missing
|
||||||
|
//! answer.
|
||||||
|
//!
|
||||||
|
//! After the summary screen is confirmed, the wizard offers the two
|
||||||
|
//! verification builds of the spec ([`offer_verification`]); a failed
|
||||||
|
//! verification never undoes the scaffold.
|
||||||
|
//!
|
||||||
|
//! The prompt calls live in `run_wizard` and `offer_verification` only;
|
||||||
|
//! everything else in this module is pure and unit-tested.
|
||||||
|
|
||||||
|
use std::error::Error;
|
||||||
|
use std::io::IsTerminal;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use indicatif::MultiProgress;
|
||||||
|
|
||||||
|
use crate::new::detect::{self, Detection};
|
||||||
|
use crate::new::options::{self, NewCli, NewOptions, SourceDir, TemplateId};
|
||||||
|
use crate::new::templates::{self, ProbeResult};
|
||||||
|
use crate::ui::prompt;
|
||||||
|
|
||||||
|
/// Answer of the "where is the source code?" question: fresh skeleton.
|
||||||
|
const SOURCE_SKELETON: &str = "Create a new project skeleton here";
|
||||||
|
/// Answer of the "where is the source code?" question: this directory.
|
||||||
|
const SOURCE_HERE: &str = "Package the sources in this directory";
|
||||||
|
/// Answer of the "where is the source code?" question: another directory.
|
||||||
|
const SOURCE_PATH: &str = "Package the sources in another directory…";
|
||||||
|
|
||||||
|
/// The "everything else" entry of the license menu.
|
||||||
|
const LICENSE_OTHER: &str = "Other (enter a SPDX identifier)";
|
||||||
|
|
||||||
|
/// The curated SPDX identifiers of the license menu (without the free-text
|
||||||
|
/// entry), matching [`options::License::parse`]'s known spellings.
|
||||||
|
pub const KNOWN_LICENSES: [&str; 9] = [
|
||||||
|
"MIT",
|
||||||
|
"Apache-2.0",
|
||||||
|
"GPL-2.0+",
|
||||||
|
"GPL-3.0+",
|
||||||
|
"LGPL-2.1+",
|
||||||
|
"LGPL-3.0+",
|
||||||
|
"BSD-2-Clause",
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"ISC",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Labels of the interactive `select` questions. `prompt::select` renders
|
||||||
|
/// `> <label><answer>` verbatim — unlike [`prompt::text`], it appends no
|
||||||
|
/// formatting of its own — so each label carries its own separator:
|
||||||
|
/// field-style prompts end with `": "`, question-style ones with `"? "`.
|
||||||
|
const LANGUAGE_LABEL: &str = "Which language/build system is your program using? ";
|
||||||
|
const SOURCE_LABEL: &str = "Where is the source code? ";
|
||||||
|
const LICENSE_LABEL: &str = "License: ";
|
||||||
|
const DIST_LABEL: &str = "Target distribution: ";
|
||||||
|
const SERIES_LABEL: &str = "Target series: ";
|
||||||
|
|
||||||
|
/// All select labels, so the separator test can check them in one place.
|
||||||
|
#[cfg(test)]
|
||||||
|
const SELECT_LABELS: [&str; 5] = [
|
||||||
|
LANGUAGE_LABEL,
|
||||||
|
SOURCE_LABEL,
|
||||||
|
LICENSE_LABEL,
|
||||||
|
DIST_LABEL,
|
||||||
|
SERIES_LABEL,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Run the `pkh new` flow: the wizard on an interactive terminal, plain
|
||||||
|
/// [`options::resolve`] otherwise (and with `--defaults`).
|
||||||
|
pub async fn run(cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
||||||
|
if cli.defaults || !is_interactive() {
|
||||||
|
return Ok(options::resolve(cli).await?);
|
||||||
|
}
|
||||||
|
run_wizard(cli).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether both ends of the terminal are interactive; the wizard and the
|
||||||
|
/// verification offers only run when this holds (the prompts' non-TTY
|
||||||
|
/// fallbacks would otherwise silently take defaults).
|
||||||
|
fn is_interactive() -> bool {
|
||||||
|
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wizard question flow (spec "Proposed UX"), in order:
|
||||||
|
/// package name, language/build system, source location, upstream version,
|
||||||
|
/// Debian revision, one-line description, homepage, license, command name,
|
||||||
|
/// maintainer, target distribution, target series, metapackage Depends
|
||||||
|
/// (`empty` template only), git init — then the summary screen and the
|
||||||
|
/// final `Generate?` confirmation. Every question with an explicit flag
|
||||||
|
/// answer is skipped (flag > detected/probe > default merge order).
|
||||||
|
async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
||||||
|
let cwd = std::env::current_dir()?;
|
||||||
|
let detect_dir = cli.source.clone().unwrap_or_else(|| cwd.clone());
|
||||||
|
let detection = detect::detect(&detect_dir);
|
||||||
|
let probe = match &detection {
|
||||||
|
Detection::Single(id) => templates::get(*id).and_then(|t| t.probe(&detect_dir)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
// Whether the flags imply a fresh skeleton (name given, no --source).
|
||||||
|
let implied_skeleton = cli.source.is_none() && cli.name.is_some();
|
||||||
|
// Whether the language question is skipped by a confident detection
|
||||||
|
// (only when packaging the detected directory, never for a skeleton).
|
||||||
|
let detection_decides = matches!(detection, Detection::Single(_)) && !implied_skeleton;
|
||||||
|
|
||||||
|
// 1. Package name: the detected project name, else the sanitized
|
||||||
|
// basename of the current directory.
|
||||||
|
if cli.name.is_none() {
|
||||||
|
let default = default_package_name(&cwd, probe.as_ref());
|
||||||
|
let answer = ask_text("Package name", &default, options::validate_source_name)?;
|
||||||
|
cli.name = Some(answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Language / build system.
|
||||||
|
let mut preselected: Option<TemplateId> = None;
|
||||||
|
match &detection {
|
||||||
|
Detection::Single(id) if detection_decides => {
|
||||||
|
log::info!(
|
||||||
|
"Detected: {} project in {}",
|
||||||
|
id.display_name(),
|
||||||
|
detect_dir.display()
|
||||||
|
);
|
||||||
|
cli.lang = Some(id.as_str().to_string());
|
||||||
|
}
|
||||||
|
Detection::Single(id) => {
|
||||||
|
// A skeleton was asked for: still ask, preselecting the
|
||||||
|
// detected ecosystem.
|
||||||
|
preselected = Some(*id);
|
||||||
|
}
|
||||||
|
Detection::Ambiguous(candidates) => {
|
||||||
|
log::info!(
|
||||||
|
"Several build systems found in {} ({}): candidates listed \
|
||||||
|
first, the highest-precedence one preselected",
|
||||||
|
detect_dir.display(),
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
let menu = language_menu(candidates);
|
||||||
|
let id = select_template(&menu, &menu[0])?;
|
||||||
|
cli.lang = Some(id.as_str().to_string());
|
||||||
|
}
|
||||||
|
Detection::Empty => {}
|
||||||
|
}
|
||||||
|
if cli.lang.is_none() {
|
||||||
|
let menu = language_menu(&[]);
|
||||||
|
let default = preselected
|
||||||
|
.unwrap_or(TemplateId::Empty)
|
||||||
|
.display_name()
|
||||||
|
.to_string();
|
||||||
|
let id = select_template(&menu, &default)?;
|
||||||
|
cli.lang = Some(id.as_str().to_string());
|
||||||
|
}
|
||||||
|
let template = TemplateId::parse(cli.lang.as_deref().unwrap_or_default())?;
|
||||||
|
|
||||||
|
// 3. Source location. Skipped (with the inline notice) when a confident
|
||||||
|
// detection already decided to package the current directory; a
|
||||||
|
// --source flag skips it too.
|
||||||
|
if cli.source.is_none() && !detection_decides {
|
||||||
|
let options = vec![
|
||||||
|
SOURCE_SKELETON.to_string(),
|
||||||
|
SOURCE_HERE.to_string(),
|
||||||
|
SOURCE_PATH.to_string(),
|
||||||
|
];
|
||||||
|
let default = if implied_skeleton {
|
||||||
|
SOURCE_SKELETON
|
||||||
|
} else {
|
||||||
|
SOURCE_HERE
|
||||||
|
};
|
||||||
|
let answer = select_from(SOURCE_LABEL, &options, default, |answer| {
|
||||||
|
options.contains(&answer.to_string())
|
||||||
|
})?;
|
||||||
|
if answer == SOURCE_HERE {
|
||||||
|
cli.source = Some(cwd.clone());
|
||||||
|
} else if answer == SOURCE_PATH {
|
||||||
|
let validator = |path: &str| validate_directory_answer(path);
|
||||||
|
let path = prompt::text("Source directory", "", Some(&validator))?;
|
||||||
|
cli.source = Some(PathBuf::from(path));
|
||||||
|
}
|
||||||
|
// SOURCE_SKELETON: cli.source stays unset (the name decides).
|
||||||
|
} else if cli.source.is_none() {
|
||||||
|
cli.source = Some(cwd.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Upstream version.
|
||||||
|
if cli.upstream_version.is_none() {
|
||||||
|
let default = probe
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.version.clone())
|
||||||
|
.unwrap_or_else(|| "0.1.0".to_string());
|
||||||
|
let revision = cli.revision.unwrap_or(1);
|
||||||
|
let answer = ask_text("Upstream version", &default, move |version: &str| {
|
||||||
|
options::validate_upstream_version(version, revision)
|
||||||
|
})?;
|
||||||
|
cli.upstream_version = Some(answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Debian revision.
|
||||||
|
if cli.revision.is_none() {
|
||||||
|
let answer = ask_text("Debian revision", "1", validate_revision_answer)?;
|
||||||
|
cli.revision = answer.parse::<u32>().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. One-line description (required: an empty answer re-asks).
|
||||||
|
if cli.description.is_none() {
|
||||||
|
let default = probe
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.description.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
loop {
|
||||||
|
let answer = ask_text(
|
||||||
|
"One-line description",
|
||||||
|
&default,
|
||||||
|
required_answer("the description"),
|
||||||
|
)?;
|
||||||
|
if !answer.trim().is_empty() {
|
||||||
|
cli.description = Some(answer);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
log::warn!("A one-line description is required to scaffold a package");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Homepage.
|
||||||
|
if cli.homepage.is_none() {
|
||||||
|
let default = probe
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.homepage.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let answer = ask_text(
|
||||||
|
"Homepage (blank to skip)",
|
||||||
|
&default,
|
||||||
|
options::validate_homepage,
|
||||||
|
)?;
|
||||||
|
if !answer.is_empty() {
|
||||||
|
cli.homepage = Some(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. License: curated SPDX menu plus a free-text entry. The default
|
||||||
|
// comes from the project metadata (Cargo.toml / pyproject.toml), then
|
||||||
|
// from sniffing the LICENSE/COPYING file.
|
||||||
|
if cli.license.is_none() {
|
||||||
|
let detected = probe
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.license.clone())
|
||||||
|
.or_else(|| detect::sniff_license(&detect_dir));
|
||||||
|
let (default, custom_default) = license_question_default(detected.as_deref());
|
||||||
|
let options = license_menu();
|
||||||
|
let answer = select_from(LICENSE_LABEL, &options, &default, |answer| {
|
||||||
|
options.contains(&answer.to_string())
|
||||||
|
})?;
|
||||||
|
if answer == LICENSE_OTHER {
|
||||||
|
let license = loop {
|
||||||
|
let candidate = ask_text(
|
||||||
|
"License (SPDX identifier)",
|
||||||
|
&custom_default,
|
||||||
|
required_answer("the license identifier"),
|
||||||
|
)?;
|
||||||
|
if !candidate.trim().is_empty() {
|
||||||
|
break candidate;
|
||||||
|
}
|
||||||
|
log::warn!("A license identifier is required when picking the free-text entry");
|
||||||
|
};
|
||||||
|
cli.license = Some(license);
|
||||||
|
} else {
|
||||||
|
cli.license = Some(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Command name (skipped for the empty template, where nothing is
|
||||||
|
// installed).
|
||||||
|
if cli.command.is_none() && template != TemplateId::Empty {
|
||||||
|
let default = probe
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.command.clone())
|
||||||
|
.unwrap_or_else(|| cli.name.clone().unwrap_or_default());
|
||||||
|
let command = ask_text(
|
||||||
|
"Command name",
|
||||||
|
&default,
|
||||||
|
required_answer("the command name"),
|
||||||
|
)?;
|
||||||
|
cli.command = Some(if command.is_empty() {
|
||||||
|
cli.name.clone().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
command
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Maintainer, defaulting to the DEBEMAIL/git-config identity (an
|
||||||
|
// empty answer re-asks).
|
||||||
|
if cli.maintainer.is_none() {
|
||||||
|
let default = crate::changelog::get_maintainer_info()
|
||||||
|
.map(|(name, email)| format!("{name} <{email}>"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let maintainer = loop {
|
||||||
|
let answer = ask_text("Maintainer", &default, |answer: &str| {
|
||||||
|
options::parse_maintainer(answer).map(|_| ())
|
||||||
|
})?;
|
||||||
|
if !answer.is_empty() {
|
||||||
|
break answer;
|
||||||
|
}
|
||||||
|
log::warn!(
|
||||||
|
"Could not determine a maintainer default (no DEBFULLNAME/\
|
||||||
|
DEBEMAIL and no git user config): answer as 'Name <email>'"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
cli.maintainer = Some(maintainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Target distribution.
|
||||||
|
if cli.dist.is_none() {
|
||||||
|
let vendor = crate::build::env::current_vendor().to_lowercase();
|
||||||
|
let options = vec!["ubuntu".to_string(), "debian".to_string()];
|
||||||
|
let default = if options.contains(&vendor) {
|
||||||
|
vendor
|
||||||
|
} else {
|
||||||
|
"ubuntu".to_string()
|
||||||
|
};
|
||||||
|
let answer = select_from(DIST_LABEL, &options, &default, |answer| {
|
||||||
|
answer == "ubuntu" || answer == "debian"
|
||||||
|
})?;
|
||||||
|
cli.dist = Some(answer);
|
||||||
|
}
|
||||||
|
let dist = cli
|
||||||
|
.dist
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| crate::build::env::current_vendor().to_lowercase());
|
||||||
|
|
||||||
|
// 12. Target series: the development series first, preselected.
|
||||||
|
if cli.series.is_none() {
|
||||||
|
match crate::distro_info::get_ordered_series_name(&dist).await {
|
||||||
|
Ok(series) if !series.is_empty() => {
|
||||||
|
let answer = prompt::select(SERIES_LABEL, &series, &series[0])?;
|
||||||
|
cli.series = Some(answer);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
log::warn!(
|
||||||
|
"Could not fetch the series list for '{dist}'; \
|
||||||
|
defaulting to its development series"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Metapackage Depends (empty template only).
|
||||||
|
if template == TemplateId::Empty && cli.depends.is_empty() {
|
||||||
|
let answer = ask_text(
|
||||||
|
"Depends (metapackage, comma-separated, blank for an empty base)",
|
||||||
|
"",
|
||||||
|
|answer: &str| options::validate_depends(answer).map(|_| ()),
|
||||||
|
)?;
|
||||||
|
if !answer.trim().is_empty() {
|
||||||
|
cli.depends = vec![answer];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. Git init.
|
||||||
|
cli.git = prompt::confirm("Initialize a git repository?", cli.git)?;
|
||||||
|
|
||||||
|
// Resolve through the same pipeline as the non-interactive path: one
|
||||||
|
// source of truth for defaults and validation.
|
||||||
|
let mut opts = options::resolve(cli).await?;
|
||||||
|
|
||||||
|
// The meson/cmake opt-in question of the spec's template table: does the
|
||||||
|
// build resolve libraries through pkg-config? The project files prefill
|
||||||
|
// the default (dependency() / pkg_check_modules calls found).
|
||||||
|
if matches!(template, TemplateId::Meson | TemplateId::Cmake)
|
||||||
|
&& prompt::confirm(
|
||||||
|
"Does the build resolve libraries through pkg-config (add it to Build-Depends)?",
|
||||||
|
pkg_config_hint(&detect_dir, template),
|
||||||
|
)?
|
||||||
|
{
|
||||||
|
opts.pkg_config = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wizard-only extras (default off).
|
||||||
|
if template != TemplateId::Empty
|
||||||
|
&& prompt::confirm(
|
||||||
|
"Add an autopkgtest smoke test (debian/tests/control)?",
|
||||||
|
false,
|
||||||
|
)?
|
||||||
|
{
|
||||||
|
opts.autopkgtest = true;
|
||||||
|
}
|
||||||
|
if let Some(watch) = watch_template(opts.homepage.as_deref())
|
||||||
|
&& prompt::confirm("Add a debian/watch release watcher?", false)?
|
||||||
|
{
|
||||||
|
opts.watch = Some(watch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary screen + final confirmation: Ctrl+C or 'n' abort with
|
||||||
|
// nothing written (generation is all-or-nothing later anyway).
|
||||||
|
println!("{}", summary_text(&opts));
|
||||||
|
if !prompt::confirm("Generate?", true)? {
|
||||||
|
return Err("Aborted: nothing was written to disk.".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The post-scaffold verification offers (spec "Verification" steps 2–3),
|
||||||
|
/// interactive only and skipped with `--no-verify`: the source build
|
||||||
|
/// (`pkh build`, offered yes) and the binary build (`pkh deb`, offered no —
|
||||||
|
/// it needs network + build deps). A failed verification build never undoes
|
||||||
|
/// the scaffold: the error is printed together with the manual next steps.
|
||||||
|
pub async fn offer_verification(opts: &NewOptions, multi: &MultiProgress, no_verify: bool) {
|
||||||
|
if no_verify || !is_interactive() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default());
|
||||||
|
let display = crate::ui::display_path(&tree);
|
||||||
|
let display = if display.is_empty() {
|
||||||
|
".".to_string()
|
||||||
|
} else {
|
||||||
|
display
|
||||||
|
};
|
||||||
|
|
||||||
|
let verify_source = match prompt::confirm("Verify with `pkh build` now?", true) {
|
||||||
|
Ok(answer) => answer,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
if !verify_source {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
|
||||||
|
if let Err(e) = crate::build::build_source_package(Some(&tree), ui) {
|
||||||
|
log::error!("Verification source build failed: {e}");
|
||||||
|
log::info!(
|
||||||
|
"The scaffolded tree is intact. Inspect it, then retry with \
|
||||||
|
`cd {display} && pkh build`."
|
||||||
|
);
|
||||||
|
log::info!(
|
||||||
|
"Hint: failures here usually come from a missing build dependency \
|
||||||
|
or build file, not from the scaffold itself; check debian/control \
|
||||||
|
and the template's build file."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let verify_deb = match prompt::confirm(
|
||||||
|
"Verify with `pkh deb` now? (needs network + build deps)",
|
||||||
|
false,
|
||||||
|
) {
|
||||||
|
Ok(answer) => answer,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
if !verify_deb {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
|
||||||
|
if let Err(e) = crate::deb::build_binary_package(
|
||||||
|
None,
|
||||||
|
Some(&opts.series),
|
||||||
|
None,
|
||||||
|
Some(&tree),
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
ui,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::error!("Verification binary build failed: {e}");
|
||||||
|
log::info!(
|
||||||
|
"The scaffolded tree is intact. Once the build dependencies are \
|
||||||
|
available, retry with `cd {display} && pkh deb`."
|
||||||
|
);
|
||||||
|
log::info!(
|
||||||
|
"Hint: when a build dependency is missing from the {} archive, \
|
||||||
|
`pkh deb --inject <package>` makes it available in the build \
|
||||||
|
environment (e.g. a PEP 517 backend like python3-poetry-core).",
|
||||||
|
opts.series
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preselected default of the pkg-config opt-in question: whether the
|
||||||
|
/// project's build file hints at pkg-config usage (`dependency(` in
|
||||||
|
/// meson.build, `pkg_check_modules` / `find_package(PkgConfig` in
|
||||||
|
/// CMakeLists.txt).
|
||||||
|
fn pkg_config_hint(dir: &std::path::Path, template: TemplateId) -> bool {
|
||||||
|
let (file, needles): (&str, &[&str]) = match template {
|
||||||
|
TemplateId::Meson => ("meson.build", &["dependency("]),
|
||||||
|
TemplateId::Cmake => (
|
||||||
|
"CMakeLists.txt",
|
||||||
|
&[
|
||||||
|
"pkg_check_modules",
|
||||||
|
"find_package(pkgconfig",
|
||||||
|
"find_package(pkg_config",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
std::fs::read_to_string(dir.join(file))
|
||||||
|
.map(|content| {
|
||||||
|
let lower = content.to_ascii_lowercase();
|
||||||
|
needles.iter().any(|needle| lower.contains(needle))
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default package name: the detected project's name, else the
|
||||||
|
/// sanitized basename of the current directory.
|
||||||
|
fn default_package_name(cwd: &std::path::Path, probe: Option<&ProbeResult>) -> String {
|
||||||
|
probe
|
||||||
|
.and_then(|p| p.name.as_deref())
|
||||||
|
.and_then(options::sanitize_name)
|
||||||
|
.or_else(|| {
|
||||||
|
cwd.file_name()
|
||||||
|
.and_then(std::ffi::OsStr::to_str)
|
||||||
|
.and_then(options::sanitize_name)
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The language menu: detected candidates first (in their detection order),
|
||||||
|
/// then every other template in registry order.
|
||||||
|
fn language_menu(candidates: &[TemplateId]) -> Vec<String> {
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.chain(
|
||||||
|
TemplateId::all()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| !candidates.contains(id)),
|
||||||
|
)
|
||||||
|
.map(|id| id.display_name().to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The license menu: the curated SPDX list plus the free-text entry.
|
||||||
|
fn license_menu() -> Vec<String> {
|
||||||
|
KNOWN_LICENSES
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(str::to_string)
|
||||||
|
.chain(std::iter::once(LICENSE_OTHER.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The defaults of the license question for a probed SPDX identifier: the
|
||||||
|
/// matching curated entry (case-insensitive) is preselected; anything else
|
||||||
|
/// preselects the free-text entry prefilled with the probe. Without a probe
|
||||||
|
/// the curated list defaults to MIT.
|
||||||
|
fn license_question_default(probe_license: Option<&str>) -> (String, String) {
|
||||||
|
match probe_license {
|
||||||
|
Some(license) => match KNOWN_LICENSES
|
||||||
|
.iter()
|
||||||
|
.find(|k| k.eq_ignore_ascii_case(license))
|
||||||
|
{
|
||||||
|
Some(known) => ((*known).to_string(), String::new()),
|
||||||
|
None => (LICENSE_OTHER.to_string(), license.to_string()),
|
||||||
|
},
|
||||||
|
None => ("MIT".to_string(), String::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the language question until a known template label (or CLI
|
||||||
|
/// identifier) is answered — the selector allows typing arbitrary text.
|
||||||
|
fn select_template(options: &[String], default: &str) -> Result<TemplateId, Box<dyn Error>> {
|
||||||
|
loop {
|
||||||
|
let answer = prompt::select(LANGUAGE_LABEL, options, default)?;
|
||||||
|
match TemplateId::from_label(&answer) {
|
||||||
|
Some(id) => return Ok(id),
|
||||||
|
None => log::warn!(
|
||||||
|
"'{answer}' is not a known template; pick one from the list \
|
||||||
|
(Tab completes its name)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask a `select` question until `accept` holds for the answer (the
|
||||||
|
/// selector allows typing arbitrary text, which callers may need to reject).
|
||||||
|
fn select_from(
|
||||||
|
label: &str,
|
||||||
|
options: &[String],
|
||||||
|
default: &str,
|
||||||
|
accept: impl Fn(&str) -> bool,
|
||||||
|
) -> Result<String, Box<dyn Error>> {
|
||||||
|
loop {
|
||||||
|
let answer = prompt::select(label, options, default)?;
|
||||||
|
if accept(&answer) {
|
||||||
|
return Ok(answer);
|
||||||
|
}
|
||||||
|
log::warn!("'{answer}' is not one of the offered answers; pick from the list");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One free-text question implementing the spec's "Enter accepts the
|
||||||
|
/// default": an empty answer falls back to `default` (`Esc` keeps its
|
||||||
|
/// prompt-level meaning of restoring the default). `validate` only ever
|
||||||
|
/// sees non-empty answers — the empty one is accepted by the prompt loop so
|
||||||
|
/// it can take the default path; callers that require an answer re-check
|
||||||
|
/// the result.
|
||||||
|
fn ask_text(
|
||||||
|
label: &str,
|
||||||
|
default: &str,
|
||||||
|
validate: impl Fn(&str) -> Result<(), String> + 'static,
|
||||||
|
) -> Result<String, Box<dyn Error>> {
|
||||||
|
let accept_empty = move |answer: &str| {
|
||||||
|
if answer.is_empty() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
validate(answer)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let answer = prompt::text(label, default, Some(&accept_empty))?;
|
||||||
|
Ok(if answer.is_empty() {
|
||||||
|
default.to_string()
|
||||||
|
} else {
|
||||||
|
answer
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validator requiring a non-empty answer.
|
||||||
|
fn required_answer(what: &str) -> impl Fn(&str) -> Result<(), String> + '_ {
|
||||||
|
move |answer: &str| {
|
||||||
|
if answer.trim().is_empty() {
|
||||||
|
Err(format!("{what} must not be empty"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validator of the source-directory answer: an existing directory.
|
||||||
|
fn validate_directory_answer(path: &str) -> Result<(), String> {
|
||||||
|
if path.trim().is_empty() {
|
||||||
|
return Err("a directory path is required".to_string());
|
||||||
|
}
|
||||||
|
if std::path::Path::new(path).is_dir() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("'{path}' is not a directory"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validator of the Debian revision answer: a positive integer.
|
||||||
|
fn validate_revision_answer(answer: &str) -> Result<(), String> {
|
||||||
|
match answer.parse::<u32>() {
|
||||||
|
Ok(0) => Err("the Debian revision must be at least 1".to_string()),
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(_) => Err(format!(
|
||||||
|
"'{answer}' is not a valid Debian revision: expected a positive integer"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `debian/watch` template for GitHub/GitLab-hosted projects; `None` when
|
||||||
|
/// the homepage is not one of those hosts (the wizard skips the question).
|
||||||
|
pub fn watch_template(homepage: Option<&str>) -> Option<String> {
|
||||||
|
let homepage = homepage?;
|
||||||
|
let (scheme, path) = homepage.split_once("://")?;
|
||||||
|
if scheme != "https" && scheme != "http" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (host, repo_path) = path.split_once('/')?;
|
||||||
|
let host = host.to_ascii_lowercase();
|
||||||
|
if host != "github.com" && host != "gitlab.com" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut segments = repo_path.trim_end_matches('/').split('/');
|
||||||
|
let owner = segments.next()?.trim_end_matches(".git");
|
||||||
|
let repo = segments.next()?.trim_end_matches(".git");
|
||||||
|
if owner.is_empty() || repo.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(format!(
|
||||||
|
"version=4\nhttps://{host}/{owner}/{repo}/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The summary screen shown before the final `Generate?` confirmation
|
||||||
|
/// (spec transcript): identity line, template/license/maintainer line, the
|
||||||
|
/// generated-file overview and — for a metapackage — the Depends payload,
|
||||||
|
/// for a skeleton — the upstream files that will be created, and for rust —
|
||||||
|
/// a warning when dependencies cannot be vendored on this host.
|
||||||
|
pub fn summary_text(opts: &NewOptions) -> String {
|
||||||
|
let template = templates::get(opts.template);
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
lines.push("────────────────────────────────────────────".to_string());
|
||||||
|
lines.push(format!(
|
||||||
|
" {} {} · builds for {}/{}",
|
||||||
|
opts.name,
|
||||||
|
opts.full_version(),
|
||||||
|
opts.dist,
|
||||||
|
opts.series
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
" {} · {} · {} <{}>",
|
||||||
|
opts.template.display_name(),
|
||||||
|
opts.license.spdx(),
|
||||||
|
opts.maintainer.0,
|
||||||
|
opts.maintainer.1
|
||||||
|
));
|
||||||
|
|
||||||
|
if let Some(template) = template {
|
||||||
|
lines.push(format!(
|
||||||
|
" debian/control Source + 1 binary (Architecture: {})",
|
||||||
|
template.architecture(opts)
|
||||||
|
));
|
||||||
|
if opts.template == TemplateId::Empty {
|
||||||
|
// The Depends list is the payload of the metapackage flavor.
|
||||||
|
if !opts.depends.is_empty() {
|
||||||
|
lines.push(format!(" Depends {}", opts.depends.join(", ")));
|
||||||
|
}
|
||||||
|
} else if opts.template == TemplateId::Rust {
|
||||||
|
lines
|
||||||
|
.push(" debian/rules cargo build --release --offline (vendored)".to_string());
|
||||||
|
} else {
|
||||||
|
lines.push(format!(" debian/rules {}", template.rules_dh_line()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let distribution = if opts.release {
|
||||||
|
opts.series.as_str()
|
||||||
|
} else {
|
||||||
|
crate::distro_info::UNRELEASED
|
||||||
|
};
|
||||||
|
lines.push(format!(
|
||||||
|
" debian/changelog {} {distribution}, Initial release",
|
||||||
|
opts.full_version()
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
" debian/copyright {} (DEP-5)",
|
||||||
|
opts.license.spdx()
|
||||||
|
));
|
||||||
|
if opts.autopkgtest {
|
||||||
|
lines.push(" debian/tests autopkgtest smoke test".to_string());
|
||||||
|
}
|
||||||
|
if opts.watch.is_some() {
|
||||||
|
lines.push(" debian/watch release watcher".to_string());
|
||||||
|
}
|
||||||
|
if matches!(opts.source_dir, SourceDir::Skeleton)
|
||||||
|
&& let Some(template) = template
|
||||||
|
{
|
||||||
|
let names: Vec<String> = template
|
||||||
|
.skeleton(opts)
|
||||||
|
.iter()
|
||||||
|
.map(|file| file.path.clone())
|
||||||
|
.collect();
|
||||||
|
if !names.is_empty() {
|
||||||
|
lines.push(format!(" + {} (new skeleton)", names.join(", ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.template == TemplateId::Rust && templates::find_on_path("cargo").is_none() {
|
||||||
|
lines.push(
|
||||||
|
" ! cargo not found on PATH: dependencies cannot be vendored at \
|
||||||
|
scaffold time; the package will not build until you run \
|
||||||
|
`cargo vendor`"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::new::options::License;
|
||||||
|
use crate::new::options::TemplateId as Tid;
|
||||||
|
|
||||||
|
fn opts(template: Tid) -> NewOptions {
|
||||||
|
NewOptions {
|
||||||
|
name: "mytool".into(),
|
||||||
|
template,
|
||||||
|
source_dir: options::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: None,
|
||||||
|
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,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_package_name_prefers_probe_then_basename() {
|
||||||
|
let probe = ProbeResult {
|
||||||
|
name: Some("My_Tool".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let cwd = std::path::Path::new("/home/user/projects");
|
||||||
|
assert_eq!(
|
||||||
|
default_package_name(cwd, Some(&probe)),
|
||||||
|
"my-tool".to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
// No probe: the directory basename, sanitized.
|
||||||
|
assert_eq!(
|
||||||
|
default_package_name(std::path::Path::new("/tmp/My Tool"), None),
|
||||||
|
"my-tool".to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nothing sane anywhere: empty (the validator forces an answer).
|
||||||
|
assert_eq!(default_package_name(std::path::Path::new("/"), None), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn language_menu_lists_candidates_first() {
|
||||||
|
let menu = language_menu(&[Tid::Makefile, Tid::Rust]);
|
||||||
|
assert_eq!(menu[0], "Generic (Makefile)");
|
||||||
|
assert_eq!(menu[1], "Rust (Cargo.toml)");
|
||||||
|
// The remaining seven follow in registry order, no duplicates.
|
||||||
|
assert_eq!(menu.len(), Tid::all().len());
|
||||||
|
let unique: std::collections::HashSet<&String> = menu.iter().collect();
|
||||||
|
assert_eq!(unique.len(), menu.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn license_menu_and_defaults() {
|
||||||
|
let menu = license_menu();
|
||||||
|
assert_eq!(menu.len(), KNOWN_LICENSES.len() + 1);
|
||||||
|
assert_eq!(menu[0], "MIT");
|
||||||
|
assert_eq!(menu.last().unwrap(), LICENSE_OTHER);
|
||||||
|
|
||||||
|
// No probe: MIT preselected, no custom prefill.
|
||||||
|
assert_eq!(
|
||||||
|
license_question_default(None),
|
||||||
|
("MIT".to_string(), String::new())
|
||||||
|
);
|
||||||
|
// Curated probe: matched case-insensitively.
|
||||||
|
assert_eq!(
|
||||||
|
license_question_default(Some("apache-2.0")),
|
||||||
|
("Apache-2.0".to_string(), String::new())
|
||||||
|
);
|
||||||
|
// Unusual probe: free-text entry prefilled.
|
||||||
|
assert_eq!(
|
||||||
|
license_question_default(Some("Zlib")),
|
||||||
|
(LICENSE_OTHER.to_string(), "Zlib".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watch_template_hosts() {
|
||||||
|
assert_eq!(
|
||||||
|
watch_template(Some("https://github.com/foo/bar")),
|
||||||
|
Some(
|
||||||
|
"version=4\nhttps://github.com/foo/bar/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||||
|
.to_string()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
watch_template(Some("https://gitlab.com/foo/bar/")),
|
||||||
|
Some(
|
||||||
|
"version=4\nhttps://gitlab.com/foo/bar/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||||
|
.to_string()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// .git suffixes and deeper paths are handled.
|
||||||
|
assert_eq!(
|
||||||
|
watch_template(Some("https://github.com/foo/bar.git/tree")),
|
||||||
|
Some(
|
||||||
|
"version=4\nhttps://github.com/foo/bar/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||||
|
.to_string()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Other hosts, no homepage, or no repo path: skipped.
|
||||||
|
assert!(watch_template(Some("https://example.com/foo/bar")).is_none());
|
||||||
|
assert!(watch_template(Some("https://github.com/foo")).is_none());
|
||||||
|
assert!(watch_template(None).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summary_screen_skeleton() {
|
||||||
|
let text = summary_text(&opts(Tid::Makefile));
|
||||||
|
assert!(
|
||||||
|
text.contains("mytool 0.1.0-1 · builds for ubuntu/resolute"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("Generic (Makefile) · MIT · Jane Doe <jane@example.com>"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("debian/control Source + 1 binary (Architecture: any)"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
assert!(text.contains("debian/rules dh $@"), "{text}");
|
||||||
|
assert!(
|
||||||
|
text.contains("debian/changelog 0.1.0-1 UNRELEASED, Initial release"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
assert!(text.contains("debian/copyright MIT (DEP-5)"), "{text}");
|
||||||
|
assert!(
|
||||||
|
text.contains("+ hello.c, Makefile (new skeleton)"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
// No extra files unless asked for.
|
||||||
|
assert!(!text.contains("debian/tests"));
|
||||||
|
assert!(!text.contains("debian/watch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summary_screen_metapackage_shows_depends() {
|
||||||
|
let mut o = opts(Tid::Empty);
|
||||||
|
o.depends = vec!["hello".to_string(), "hello-data (>= 1.0)".to_string()];
|
||||||
|
o.source_dir = options::SourceDir::Here;
|
||||||
|
let text = summary_text(&o);
|
||||||
|
assert!(text.contains("Architecture: all"), "{text}");
|
||||||
|
assert!(
|
||||||
|
text.contains("Depends hello, hello-data (>= 1.0)"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
// Build info is replaced by the Depends payload; no skeleton line.
|
||||||
|
assert!(!text.contains("debian/rules"), "{text}");
|
||||||
|
assert!(!text.contains("(new skeleton)"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summary_screen_release_and_extras() {
|
||||||
|
let mut o = opts(Tid::Shell);
|
||||||
|
o.release = true;
|
||||||
|
o.autopkgtest = true;
|
||||||
|
o.watch = Some("version=4\n".to_string());
|
||||||
|
let text = summary_text(&o);
|
||||||
|
assert!(text.contains("0.1.0-1 resolute, Initial release"), "{text}");
|
||||||
|
assert!(
|
||||||
|
text.contains("debian/tests autopkgtest smoke test"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
assert!(text.contains("debian/watch release watcher"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn answer_validators() {
|
||||||
|
assert!(validate_revision_answer("1").is_ok());
|
||||||
|
assert!(validate_revision_answer("0").is_err());
|
||||||
|
assert!(validate_revision_answer("x").is_err());
|
||||||
|
|
||||||
|
assert!(validate_directory_answer("/tmp").is_ok());
|
||||||
|
assert!(validate_directory_answer("").is_err());
|
||||||
|
assert!(validate_directory_answer("/definitely/not/here").is_err());
|
||||||
|
|
||||||
|
assert!(required_answer("x")("").is_err());
|
||||||
|
assert!(required_answer("x")("ok").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_labels_carry_their_own_separator() {
|
||||||
|
// prompt::select renders `> <label><answer>` verbatim; a label
|
||||||
|
// without a trailing separator glues the answer to the prompt
|
||||||
|
// (regression: the wizard once rendered "> LicenseMIT").
|
||||||
|
for label in SELECT_LABELS {
|
||||||
|
assert!(
|
||||||
|
label.ends_with(": ") || label.ends_with("? "),
|
||||||
|
"select label {label:?} lacks a trailing separator"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
//! The `autotools` template: a C project with a `configure.ac` built through
|
||||||
|
//! debhelper's auto-detection (dh runs `autoreconf` itself when it finds
|
||||||
|
//! `configure.ac`, debhelper ≥ 10 — no override needed).
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
use super::meson::hello_c;
|
||||||
|
use super::{OutputFile, ProbeResult, Template, source_dir_of};
|
||||||
|
use crate::new::options::{NewOptions, TemplateId};
|
||||||
|
|
||||||
|
/// C/C++ with Autotools (`configure.ac`).
|
||||||
|
pub struct Autotools;
|
||||||
|
|
||||||
|
impl Template for Autotools {
|
||||||
|
fn id(&self) -> TemplateId {
|
||||||
|
TemplateId::Autotools
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal `configure.ac`, the matching `Makefile.am` and `hello.c`.
|
||||||
|
/// The first source build runs `autoreconf` (integrated in the dh
|
||||||
|
/// sequence), so no generated configure script is committed.
|
||||||
|
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
vec![
|
||||||
|
OutputFile::new(
|
||||||
|
"configure.ac",
|
||||||
|
format!(
|
||||||
|
"AC_INIT([{name}], [{version}])\n\
|
||||||
|
AM_INIT_AUTOMAKE([foreign])\n\
|
||||||
|
AC_PROG_CC\n\
|
||||||
|
AC_CONFIG_FILES([Makefile])\n\
|
||||||
|
AC_OUTPUT\n",
|
||||||
|
name = opts.name,
|
||||||
|
version = opts.upstream_version,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OutputFile::new(
|
||||||
|
"Makefile.am",
|
||||||
|
format!(
|
||||||
|
"bin_PROGRAMS = {command}\n\
|
||||||
|
{command}_SOURCES = hello.c\n",
|
||||||
|
command = opts.command,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
hello_c(opts),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No extra debian/ files: plain `dh $@` auto-detects `configure.ac`.
|
||||||
|
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
|
||||||
|
let mut deps = vec![
|
||||||
|
"autoconf".to_string(),
|
||||||
|
"automake".to_string(),
|
||||||
|
"libtool".to_string(),
|
||||||
|
];
|
||||||
|
if uses_gettext(opts) {
|
||||||
|
deps.push("gettext".to_string());
|
||||||
|
}
|
||||||
|
deps
|
||||||
|
}
|
||||||
|
|
||||||
|
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||||
|
"any"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Package name and version from the `AC_INIT` macro of `configure.ac`.
|
||||||
|
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("configure.ac")).ok()?;
|
||||||
|
static AC_INIT_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||||
|
// AC_INIT([name], [version]) — the autoconf quotes are optional.
|
||||||
|
let regex = AC_INIT_REGEX.get_or_init(|| {
|
||||||
|
Regex::new(
|
||||||
|
r"AC_INIT\s*\(\s*(?:\[([^\]]*)\]|([^,\s\[]+))\s*,\s*(?:\[([^\]]*)\]|([^,\s\[]+))",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
let caps = regex.captures(&content)?;
|
||||||
|
let value = |bracketed: usize, bare: usize| {
|
||||||
|
caps.get(bracketed)
|
||||||
|
.or_else(|| caps.get(bare))
|
||||||
|
.map(|v| v.as_str().trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
};
|
||||||
|
let name = value(1, 2)?;
|
||||||
|
let version = value(3, 4);
|
||||||
|
Some(ProbeResult {
|
||||||
|
name: Some(name),
|
||||||
|
version,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the packaged `configure.ac` sets up GNU gettext (`AM_GNU_GETTEXT`
|
||||||
|
/// macro): those builds need the `gettext` package. Only meaningful when
|
||||||
|
/// packaging an existing tree (the generated skeleton carries no gettext).
|
||||||
|
fn uses_gettext(opts: &NewOptions) -> bool {
|
||||||
|
source_dir_of(opts).is_some_and(|dir| {
|
||||||
|
std::fs::read_to_string(dir.join("configure.ac"))
|
||||||
|
.is_ok_and(|content| content.contains("AM_GNU_GETTEXT"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::new::options::{License, SourceDir};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn opts() -> NewOptions {
|
||||||
|
NewOptions {
|
||||||
|
name: "mytool".into(),
|
||||||
|
template: TemplateId::Autotools,
|
||||||
|
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,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn autotools_template_shape() {
|
||||||
|
let o = opts();
|
||||||
|
let template = super::super::get(TemplateId::Autotools).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(template.architecture(&o), "any");
|
||||||
|
assert_eq!(
|
||||||
|
template.build_depends(&o),
|
||||||
|
vec![
|
||||||
|
"autoconf".to_string(),
|
||||||
|
"automake".to_string(),
|
||||||
|
"libtool".to_string()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(template.rules_dh_line(), "dh $@");
|
||||||
|
assert!(template.rules_extra(&o).is_empty());
|
||||||
|
assert!(template.debian(&o).is_empty());
|
||||||
|
|
||||||
|
let skeleton = template.skeleton(&o);
|
||||||
|
let configure = skeleton
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "configure.ac")
|
||||||
|
.expect("configure.ac skeleton");
|
||||||
|
assert!(
|
||||||
|
configure
|
||||||
|
.contents
|
||||||
|
.starts_with("AC_INIT([mytool], [0.1.0])\n")
|
||||||
|
);
|
||||||
|
let makefile_am = skeleton
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "Makefile.am")
|
||||||
|
.expect("Makefile.am skeleton");
|
||||||
|
assert!(makefile_am.contents.contains("bin_PROGRAMS = mytool"));
|
||||||
|
assert!(makefile_am.contents.contains("mytool_SOURCES = hello.c"));
|
||||||
|
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn autotools_probe_reads_ac_init() {
|
||||||
|
let template = super::super::get(TemplateId::Autotools).unwrap();
|
||||||
|
|
||||||
|
// Bracketed form (the generated skeleton's own shape).
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("configure.ac"),
|
||||||
|
"AC_INIT([mytool], [0.1.0])\nAM_INIT_AUTOMAKE([foreign])\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
||||||
|
assert_eq!(probe.version.as_deref(), Some("0.1.0"));
|
||||||
|
|
||||||
|
// Bare form with a bug-report address as the third argument.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("configure.ac"),
|
||||||
|
"AC_INIT(mytool, 1.2.3, bugs@example.com)\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
||||||
|
assert_eq!(probe.version.as_deref(), Some("1.2.3"));
|
||||||
|
|
||||||
|
// Name with spaces inside the brackets.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("configure.ac"),
|
||||||
|
"AC_INIT([My Tool], [2.0])\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("My Tool"));
|
||||||
|
|
||||||
|
// No configure.ac: silent None.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
assert!(template.probe(dir.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gettext_detected_in_configure_ac() {
|
||||||
|
// Skeleton: the generated configure.ac has no gettext.
|
||||||
|
let o = opts();
|
||||||
|
assert!(!uses_gettext(&o));
|
||||||
|
assert!(
|
||||||
|
!super::super::get(TemplateId::Autotools)
|
||||||
|
.unwrap()
|
||||||
|
.build_depends(&o)
|
||||||
|
.contains(&"gettext".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Existing tree with AM_GNU_GETTEXT: gettext joins Build-Depends.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("configure.ac"),
|
||||||
|
"AC_INIT([mytool], [0.1.0])\nAM_GNU_GETTEXT([external])\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let o = NewOptions {
|
||||||
|
source_dir: SourceDir::Path(dir.path().to_path_buf()),
|
||||||
|
..opts()
|
||||||
|
};
|
||||||
|
assert!(uses_gettext(&o));
|
||||||
|
assert!(
|
||||||
|
super::super::get(TemplateId::Autotools)
|
||||||
|
.unwrap()
|
||||||
|
.build_depends(&o)
|
||||||
|
.contains(&"gettext".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//! The `cmake` template: a C/C++ project built with CMake through the
|
||||||
|
//! debhelper cmake buildsystem.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
use super::meson::hello_c;
|
||||||
|
use super::{OutputFile, ProbeResult, Template};
|
||||||
|
use crate::new::options::{NewOptions, TemplateId};
|
||||||
|
|
||||||
|
/// C/C++ with CMake (`CMakeLists.txt`).
|
||||||
|
pub struct Cmake;
|
||||||
|
|
||||||
|
impl Template for Cmake {
|
||||||
|
fn id(&self) -> TemplateId {
|
||||||
|
TemplateId::Cmake
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal `CMakeLists.txt` (project declaration + one installed
|
||||||
|
/// executable) and the classic `hello.c`.
|
||||||
|
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
vec![
|
||||||
|
OutputFile::new(
|
||||||
|
"CMakeLists.txt",
|
||||||
|
format!(
|
||||||
|
"cmake_minimum_required(VERSION 3.16)\n\
|
||||||
|
project({name} VERSION {version})\n\
|
||||||
|
\n\
|
||||||
|
add_executable({command} hello.c)\n\
|
||||||
|
install(TARGETS {command} RUNTIME DESTINATION bin)\n",
|
||||||
|
name = opts.name,
|
||||||
|
version = opts.upstream_version,
|
||||||
|
command = opts.command,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
hello_c(opts),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No extra debian/ files: debhelper's cmake buildsystem handles the
|
||||||
|
/// configure/build/install steps.
|
||||||
|
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
|
||||||
|
let mut deps = vec!["cmake".to_string()];
|
||||||
|
if opts.pkg_config {
|
||||||
|
deps.push("pkg-config".to_string());
|
||||||
|
}
|
||||||
|
deps
|
||||||
|
}
|
||||||
|
|
||||||
|
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||||
|
"any"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rules_dh_line(&self) -> String {
|
||||||
|
"dh $@ --buildsystem=cmake".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project name and version from the `project(<name> VERSION …)`
|
||||||
|
/// declaration.
|
||||||
|
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("CMakeLists.txt")).ok()?;
|
||||||
|
static PROJECT_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||||
|
let regex = PROJECT_REGEX.get_or_init(|| {
|
||||||
|
Regex::new(r"(?im)^\s*project\s*\(\s*([A-Za-z0-9_][A-Za-z0-9_.\-]*)(?:\s+VERSION\s+([0-9][^\s)]*))?").unwrap()
|
||||||
|
});
|
||||||
|
let caps = regex.captures(&content)?;
|
||||||
|
let version = caps
|
||||||
|
.get(2)
|
||||||
|
.map(|v| v.as_str().trim_end_matches('.').to_string())
|
||||||
|
.filter(|v| !v.is_empty());
|
||||||
|
Some(ProbeResult {
|
||||||
|
name: Some(caps[1].to_string()),
|
||||||
|
version,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::new::options::{License, SourceDir};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn opts() -> NewOptions {
|
||||||
|
NewOptions {
|
||||||
|
name: "mytool".into(),
|
||||||
|
template: TemplateId::Cmake,
|
||||||
|
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,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cmake_template_shape() {
|
||||||
|
let o = opts();
|
||||||
|
let template = super::super::get(TemplateId::Cmake).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(template.architecture(&o), "any");
|
||||||
|
assert_eq!(template.build_depends(&o), vec!["cmake".to_string()]);
|
||||||
|
assert_eq!(template.rules_dh_line(), "dh $@ --buildsystem=cmake");
|
||||||
|
assert!(template.rules_extra(&o).is_empty());
|
||||||
|
assert!(template.debian(&o).is_empty());
|
||||||
|
|
||||||
|
let skeleton = template.skeleton(&o);
|
||||||
|
let cmakelists = skeleton
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "CMakeLists.txt")
|
||||||
|
.expect("CMakeLists.txt skeleton");
|
||||||
|
assert!(
|
||||||
|
cmakelists
|
||||||
|
.contents
|
||||||
|
.contains("project(mytool VERSION 0.1.0)")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
cmakelists
|
||||||
|
.contents
|
||||||
|
.contains("add_executable(mytool hello.c)")
|
||||||
|
);
|
||||||
|
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cmake_probe_reads_project_declaration() {
|
||||||
|
let template = super::super::get(TemplateId::Cmake).unwrap();
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("CMakeLists.txt"),
|
||||||
|
"cmake_minimum_required(VERSION 3.16)\n\
|
||||||
|
project(mytool VERSION 1.2.3 LANGUAGES C)\n\
|
||||||
|
add_executable(mytool hello.c)\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
||||||
|
assert_eq!(probe.version.as_deref(), Some("1.2.3"));
|
||||||
|
|
||||||
|
// Lowercase keyword, name without version.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("CMakeLists.txt"),
|
||||||
|
"project( just_a_name LANGUAGES CXX )\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("just_a_name"));
|
||||||
|
assert_eq!(probe.version, None);
|
||||||
|
|
||||||
|
// No CMakeLists.txt: silent None.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
assert!(template.probe(dir.path()).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! 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<OutputFile> {
|
||||||
|
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<OutputFile> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::new::options::{License, SourceDir, TemplateId};
|
||||||
|
|
||||||
|
fn opts(depends: Vec<String>) -> 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,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(&o), "all");
|
||||||
|
assert!(template.build_depends(&o).is_empty());
|
||||||
|
assert!(template.rules_extra(&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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! The `go` template: a Go module built through dh-golang.
|
||||||
|
//!
|
||||||
|
//! The source stanza carries `XS-Go-Import-Path`, probed from the `module`
|
||||||
|
//! line of `go.mod` when the packaged tree has one, defaulting to the
|
||||||
|
//! package name (fresh skeletons embed the package name in their own
|
||||||
|
//! `go.mod`).
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::{OutputFile, Template, source_dir_of};
|
||||||
|
use crate::new::options::{NewOptions, TemplateId};
|
||||||
|
|
||||||
|
/// Go module (`go.mod`).
|
||||||
|
pub struct Go;
|
||||||
|
|
||||||
|
impl Template for Go {
|
||||||
|
fn id(&self) -> TemplateId {
|
||||||
|
TemplateId::Go
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stdlib-only `main.go` (no archive dependencies needed to build) and
|
||||||
|
/// the matching `go.mod` whose module path is the package name.
|
||||||
|
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
vec![
|
||||||
|
OutputFile::new(
|
||||||
|
"go.mod",
|
||||||
|
format!(
|
||||||
|
"module {name}\n\
|
||||||
|
\n\
|
||||||
|
go 1.21\n",
|
||||||
|
name = opts.name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OutputFile::new(
|
||||||
|
"main.go",
|
||||||
|
format!(
|
||||||
|
"// Placeholder for {name}, generated by `pkh new`.\n\
|
||||||
|
package main\n\
|
||||||
|
\n\
|
||||||
|
import \"fmt\"\n\
|
||||||
|
\n\
|
||||||
|
func main() {{\n\
|
||||||
|
\tfmt.Println(\"Hello from {command}!\")\n\
|
||||||
|
}}\n",
|
||||||
|
name = opts.name,
|
||||||
|
command = opts.command,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No extra debian/ files: dh-golang drives the build.
|
||||||
|
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
|
||||||
|
vec!["golang-any".to_string(), "dh-golang".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||||
|
"any"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rules_dh_line(&self) -> String {
|
||||||
|
"dh $@ --buildsystem=golang".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_fields(&self, opts: &NewOptions) -> Vec<(String, String)> {
|
||||||
|
vec![("XS-Go-Import-Path".to_string(), import_path(opts))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name (and default command) from the `module` line of `go.mod`: the
|
||||||
|
/// last path segment is the conventional binary/package name.
|
||||||
|
fn probe(&self, dir: &Path) -> Option<super::ProbeResult> {
|
||||||
|
let module = read_module_line(dir)?;
|
||||||
|
let name = module.rsplit('/').next()?.to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(super::ProbeResult {
|
||||||
|
name: Some(name.clone()),
|
||||||
|
command: Some(name),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `XS-Go-Import-Path` value: probed from `go.mod` when the packaged
|
||||||
|
/// tree has one, the package name otherwise (skeleton mode embeds the name
|
||||||
|
/// in the generated `go.mod` anyway).
|
||||||
|
fn import_path(opts: &NewOptions) -> String {
|
||||||
|
source_dir_of(opts)
|
||||||
|
.as_deref()
|
||||||
|
.and_then(read_module_line)
|
||||||
|
.unwrap_or_else(|| opts.name.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `module <path>` line of `dir/go.mod`, when present.
|
||||||
|
fn read_module_line(dir: &Path) -> Option<String> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("go.mod")).ok()?;
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if let Some(rest) = line.strip_prefix("module ") {
|
||||||
|
let module = rest.trim();
|
||||||
|
if !module.is_empty() {
|
||||||
|
return Some(module.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::new::options::{License, SourceDir};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn opts(source_dir: SourceDir) -> NewOptions {
|
||||||
|
NewOptions {
|
||||||
|
name: "mytool".into(),
|
||||||
|
template: TemplateId::Go,
|
||||||
|
source_dir,
|
||||||
|
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,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn go_template_shape() {
|
||||||
|
let o = opts(SourceDir::Skeleton);
|
||||||
|
let template = super::super::get(TemplateId::Go).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(template.architecture(&o), "any");
|
||||||
|
assert_eq!(
|
||||||
|
template.build_depends(&o),
|
||||||
|
vec!["golang-any".to_string(), "dh-golang".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(template.rules_dh_line(), "dh $@ --buildsystem=golang");
|
||||||
|
assert!(template.debian(&o).is_empty());
|
||||||
|
|
||||||
|
let skeleton = template.skeleton(&o);
|
||||||
|
assert!(
|
||||||
|
skeleton
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.path == "go.mod" && f.contents.starts_with("module mytool\n"))
|
||||||
|
);
|
||||||
|
assert!(skeleton.iter().any(|f| f.path == "main.go"));
|
||||||
|
|
||||||
|
// Skeleton mode: no go.mod to probe, the import path is the name.
|
||||||
|
assert_eq!(
|
||||||
|
template.source_fields(&o),
|
||||||
|
vec![("XS-Go-Import-Path".to_string(), "mytool".to_string())]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn go_probe_reads_module_line() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("go.mod"),
|
||||||
|
"module example.com/org/mytool\n\ngo 1.21\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let template = super::super::get(TemplateId::Go).unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
||||||
|
assert_eq!(probe.command.as_deref(), Some("mytool"));
|
||||||
|
|
||||||
|
// The probed module line wins over the package name for the import
|
||||||
|
// path when packaging an existing tree.
|
||||||
|
let o = opts(SourceDir::Path(dir.path().to_path_buf()));
|
||||||
|
assert_eq!(
|
||||||
|
template.source_fields(&o),
|
||||||
|
vec![(
|
||||||
|
"XS-Go-Import-Path".to_string(),
|
||||||
|
"example.com/org/mytool".to_string()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// No go.mod: no probe result.
|
||||||
|
let empty = tempdir().unwrap();
|
||||||
|
assert!(template.probe(empty.path()).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
//! The `makefile` template: a generic project driven by a plain `Makefile`.
|
||||||
|
//!
|
||||||
|
//! debhelper's makefile buildsystem runs `make` for the build and
|
||||||
|
//! `make install DESTDIR=...` when the Makefile carries an `install:` target
|
||||||
|
//! (missing targets are skipped gracefully), so plain `dh $@` plumbing is
|
||||||
|
//! enough here.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::{OutputFile, Template, source_dir_of};
|
||||||
|
use crate::new::options::{NewOptions, SourceDir, TemplateId};
|
||||||
|
|
||||||
|
/// Generic Makefile-based project.
|
||||||
|
pub struct Makefile;
|
||||||
|
|
||||||
|
impl Template for Makefile {
|
||||||
|
fn id(&self) -> TemplateId {
|
||||||
|
TemplateId::Makefile
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `hello.c` plus a `Makefile` with `all`/`install`/`clean` targets;
|
||||||
|
/// `install` honors `DESTDIR` and copies the binary to
|
||||||
|
/// `$(DESTDIR)/usr/bin/`.
|
||||||
|
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
vec![
|
||||||
|
super::meson::hello_c(opts),
|
||||||
|
OutputFile::new(
|
||||||
|
"Makefile",
|
||||||
|
format!(
|
||||||
|
"CC ?= cc\n\
|
||||||
|
CFLAGS ?= -O2 -Wall -Wextra\n\
|
||||||
|
PREFIX ?= /usr\n\
|
||||||
|
\n\
|
||||||
|
all: {command}\n\
|
||||||
|
\n\
|
||||||
|
{command}: hello.c\n\
|
||||||
|
\t$(CC) $(CFLAGS) -o $@ hello.c\n\
|
||||||
|
\n\
|
||||||
|
install: {command}\n\
|
||||||
|
\tinstall -Dm755 {command} $(DESTDIR)$(PREFIX)/bin/{command}\n\
|
||||||
|
\n\
|
||||||
|
clean:\n\
|
||||||
|
\trm -f {command}\n\
|
||||||
|
\n\
|
||||||
|
.PHONY: all install clean\n",
|
||||||
|
command = opts.command,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `debian/install` mapping the built binary into `/usr/bin`, generated
|
||||||
|
/// only when the packaged Makefile carries a phony `install:` target:
|
||||||
|
/// for skeletons that is known by construction; when packaging an
|
||||||
|
/// existing tree the Makefile is probed instead (no `debian/install` is
|
||||||
|
/// emitted there — the source-relative mapping of an unknown artifact is
|
||||||
|
/// only the project's to write, and `make install` already ran).
|
||||||
|
fn debian(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
match &opts.source_dir {
|
||||||
|
SourceDir::Skeleton => {
|
||||||
|
vec![OutputFile::new(
|
||||||
|
"debian/install",
|
||||||
|
format!("{} usr/bin/{}\n", opts.command, opts.command),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(dir) = source_dir_of(opts)
|
||||||
|
&& phony_install_target(&dir.join("Makefile")).is_some()
|
||||||
|
{
|
||||||
|
log::info!(
|
||||||
|
"Makefile carries a phony 'install:' target: \
|
||||||
|
dh_auto_install will run 'make install DESTDIR=...'"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"Makefile has no phony 'install:' target: \
|
||||||
|
dh_auto_install will skip the install step; write a \
|
||||||
|
debian/install file to map build artifacts manually"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
|
||||||
|
vec!["build-essential".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||||
|
"any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name of the phony `install:` target of the Makefile at `path`, when
|
||||||
|
/// there is one: an unindented `install:` rule whose name also appears in a
|
||||||
|
/// `.PHONY:` declaration. `None` when the file is missing or carries no such
|
||||||
|
/// target. (A deliberately minimal line-oriented heuristic, like the other
|
||||||
|
/// project-file readers of the templates.)
|
||||||
|
pub fn phony_install_target(path: &Path) -> Option<String> {
|
||||||
|
let content = std::fs::read_to_string(path).ok()?;
|
||||||
|
let mut has_install_rule = false;
|
||||||
|
let mut phony_mentions_install = false;
|
||||||
|
for line in content.lines() {
|
||||||
|
if let Some(phony) = line.strip_prefix(".PHONY:")
|
||||||
|
&& phony.split_whitespace().any(|target| target == "install")
|
||||||
|
{
|
||||||
|
phony_mentions_install = true;
|
||||||
|
}
|
||||||
|
// Target rules start at column 0; recipes are indented, and special
|
||||||
|
// targets, comments and directives are excluded by the prefix check.
|
||||||
|
if !line.starts_with(['\t', ' ', '.', '#']) && line.starts_with("install:") {
|
||||||
|
has_install_rule = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(has_install_rule && phony_mentions_install).then(|| "install".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::new::options::{License, SourceDir};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn opts(source_dir: SourceDir) -> NewOptions {
|
||||||
|
NewOptions {
|
||||||
|
name: "mytool".into(),
|
||||||
|
template: TemplateId::Makefile,
|
||||||
|
source_dir,
|
||||||
|
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,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn makefile_template_shape() {
|
||||||
|
let o = opts(SourceDir::Skeleton);
|
||||||
|
let template = super::super::get(TemplateId::Makefile).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(template.architecture(&o), "any");
|
||||||
|
assert_eq!(
|
||||||
|
template.build_depends(&o),
|
||||||
|
vec!["build-essential".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(template.rules_dh_line(), "dh $@");
|
||||||
|
assert!(template.rules_extra(&o).is_empty());
|
||||||
|
|
||||||
|
// Skeleton: hello.c + Makefile with all/install/clean, and the
|
||||||
|
// phony install target maps to debian/install.
|
||||||
|
let skeleton = template.skeleton(&o);
|
||||||
|
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
|
||||||
|
let makefile = skeleton
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "Makefile")
|
||||||
|
.expect("Makefile skeleton");
|
||||||
|
assert!(makefile.contents.contains("all: mytool\n"));
|
||||||
|
assert!(
|
||||||
|
makefile
|
||||||
|
.contents
|
||||||
|
.contains("install -Dm755 mytool $(DESTDIR)$(PREFIX)/bin/mytool")
|
||||||
|
);
|
||||||
|
assert!(makefile.contents.contains(".PHONY: all install clean"));
|
||||||
|
assert!(
|
||||||
|
makefile
|
||||||
|
.contents
|
||||||
|
.contains("\t$(CC) $(CFLAGS) -o $@ hello.c")
|
||||||
|
);
|
||||||
|
|
||||||
|
let debian = template.debian(&o);
|
||||||
|
assert_eq!(debian.len(), 1);
|
||||||
|
assert_eq!(debian[0].path, "debian/install");
|
||||||
|
assert_eq!(debian[0].contents, "mytool usr/bin/mytool\n");
|
||||||
|
|
||||||
|
// Existing tree: nothing is emitted (probe log only).
|
||||||
|
let o = opts(SourceDir::Here);
|
||||||
|
assert!(template.debian(&o).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn phony_install_target_detection() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("Makefile");
|
||||||
|
|
||||||
|
// Phony install target: detected.
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
"all:\n\t@echo\n\n.PHONY: all install clean\ninstall:\n\t@echo install\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(phony_install_target(&path).is_some());
|
||||||
|
|
||||||
|
// install: without .PHONY: not detected.
|
||||||
|
std::fs::write(&path, "all:\n\t@echo\ninstall:\n\t@echo install\n").unwrap();
|
||||||
|
assert!(phony_install_target(&path).is_none());
|
||||||
|
|
||||||
|
// .PHONY mentioning install but no install: rule: not detected.
|
||||||
|
std::fs::write(&path, ".PHONY: install\nall:\n\t@echo\n").unwrap();
|
||||||
|
assert!(phony_install_target(&path).is_none());
|
||||||
|
|
||||||
|
// Missing file.
|
||||||
|
assert!(phony_install_target(&dir.path().join("missing.mk")).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! The `meson` template: a C/C++ project built with Meson through the
|
||||||
|
//! debhelper meson buildsystem.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
use super::{OutputFile, ProbeResult, Template};
|
||||||
|
use crate::new::options::{NewOptions, TemplateId};
|
||||||
|
|
||||||
|
/// C/C++ with Meson (`meson.build`).
|
||||||
|
pub struct Meson;
|
||||||
|
|
||||||
|
impl Template for Meson {
|
||||||
|
fn id(&self) -> TemplateId {
|
||||||
|
TemplateId::Meson
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal `meson.build` (project declaration + one installed
|
||||||
|
/// executable) and the classic `hello.c`.
|
||||||
|
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
vec![
|
||||||
|
OutputFile::new(
|
||||||
|
"meson.build",
|
||||||
|
format!(
|
||||||
|
"project('{name}', version: '{version}', license: '{license}', \
|
||||||
|
default_options: ['c_std=c11'])\n\
|
||||||
|
\n\
|
||||||
|
executable('{command}', 'hello.c', install: true)\n",
|
||||||
|
name = opts.name,
|
||||||
|
version = opts.upstream_version,
|
||||||
|
license = opts.license.spdx(),
|
||||||
|
command = opts.command,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
hello_c(opts),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No extra debian/ files: debhelper's meson buildsystem handles the
|
||||||
|
/// configure/build/install steps.
|
||||||
|
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
|
||||||
|
let mut deps = vec!["meson".to_string()];
|
||||||
|
if opts.pkg_config {
|
||||||
|
deps.push("pkg-config".to_string());
|
||||||
|
}
|
||||||
|
deps
|
||||||
|
}
|
||||||
|
|
||||||
|
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||||
|
"any"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rules_dh_line(&self) -> String {
|
||||||
|
"dh $@ --buildsystem=meson".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project name and version from the `project('name', version: …)`
|
||||||
|
/// declaration.
|
||||||
|
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("meson.build")).ok()?;
|
||||||
|
static PROJECT_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||||
|
let regex = PROJECT_REGEX.get_or_init(|| {
|
||||||
|
Regex::new(r"(?m)^\s*project\(\s*'([^']+)'\s*(?:,\s*version\s*:\s*'([^']+)')?").unwrap()
|
||||||
|
});
|
||||||
|
let caps = regex.captures(&content)?;
|
||||||
|
Some(ProbeResult {
|
||||||
|
name: Some(caps[1].to_string()),
|
||||||
|
version: caps.get(2).map(|v| v.as_str().to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared `hello.c` placeholder of the C/C++ skeletons.
|
||||||
|
pub(super) fn hello_c(opts: &NewOptions) -> OutputFile {
|
||||||
|
OutputFile::new(
|
||||||
|
"hello.c",
|
||||||
|
format!(
|
||||||
|
"#include <stdio.h>\n\
|
||||||
|
\n\
|
||||||
|
/* Placeholder for {name}, generated by `pkh new`. */\n\
|
||||||
|
int main(void)\n\
|
||||||
|
{{\n\
|
||||||
|
\tprintf(\"Hello from {command}!\\n\");\n\
|
||||||
|
\treturn 0;\n\
|
||||||
|
}}\n",
|
||||||
|
name = opts.name,
|
||||||
|
command = opts.command,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::new::options::{License, SourceDir};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn opts() -> NewOptions {
|
||||||
|
NewOptions {
|
||||||
|
name: "mytool".into(),
|
||||||
|
template: TemplateId::Meson,
|
||||||
|
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,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn meson_template_shape() {
|
||||||
|
let o = opts();
|
||||||
|
let template = super::super::get(TemplateId::Meson).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(template.architecture(&o), "any");
|
||||||
|
assert_eq!(template.build_depends(&o), vec!["meson".to_string()]);
|
||||||
|
assert_eq!(template.rules_dh_line(), "dh $@ --buildsystem=meson");
|
||||||
|
assert!(template.rules_extra(&o).is_empty());
|
||||||
|
assert!(template.debian(&o).is_empty());
|
||||||
|
|
||||||
|
let skeleton = template.skeleton(&o);
|
||||||
|
let meson_build = skeleton
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "meson.build")
|
||||||
|
.expect("meson.build skeleton");
|
||||||
|
assert!(meson_build.contents.contains("project('mytool'"));
|
||||||
|
assert!(meson_build.contents.contains("version: '0.1.0'"));
|
||||||
|
assert!(
|
||||||
|
meson_build
|
||||||
|
.contents
|
||||||
|
.contains("executable('mytool', 'hello.c', install: true)")
|
||||||
|
);
|
||||||
|
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn meson_probe_reads_project_declaration() {
|
||||||
|
let template = super::super::get(TemplateId::Meson).unwrap();
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("meson.build"),
|
||||||
|
"project('mytool', version: '1.2.3', license: 'MIT', default_options: ['c_std=c11'])\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
||||||
|
assert_eq!(probe.version.as_deref(), Some("1.2.3"));
|
||||||
|
|
||||||
|
// Version is optional in project().
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("meson.build"), "project('just-a-name')\n").unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("just-a-name"));
|
||||||
|
assert_eq!(probe.version, None);
|
||||||
|
|
||||||
|
// No meson.build: silent None.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
assert!(template.probe(dir.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pkg_config_opt_in_extends_build_depends() {
|
||||||
|
let template = super::super::get(TemplateId::Meson).unwrap();
|
||||||
|
let mut o = opts();
|
||||||
|
assert_eq!(template.build_depends(&o), vec!["meson".to_string()]);
|
||||||
|
o.pkg_config = true;
|
||||||
|
assert_eq!(
|
||||||
|
template.build_depends(&o),
|
||||||
|
vec!["meson".to_string(), "pkg-config".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
//! 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, probes an
|
||||||
|
//! existing project for metadata used to pre-fill the wizard answers, and
|
||||||
|
//! describes its Build-Depends / architecture / `debian/rules` shape.
|
||||||
|
//! Rendering is plain `format!` composition — no template engine, matching
|
||||||
|
//! the codebase style.
|
||||||
|
|
||||||
|
pub mod autotools;
|
||||||
|
pub mod cmake;
|
||||||
|
pub mod empty;
|
||||||
|
pub mod go;
|
||||||
|
pub mod makefile;
|
||||||
|
pub mod meson;
|
||||||
|
pub mod python;
|
||||||
|
pub mod rust;
|
||||||
|
pub mod shell;
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::options::{NewOptions, SourceDir, 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<String>, contents: impl Into<String>) -> OutputFile {
|
||||||
|
OutputFile {
|
||||||
|
path: path.into(),
|
||||||
|
contents: contents.into(),
|
||||||
|
executable: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An executable file (mode 0755).
|
||||||
|
pub fn executable(path: impl Into<String>, contents: impl Into<String>) -> 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). Every field is optional; probe failures are silent and the generic
|
||||||
|
/// defaults apply.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ProbeResult {
|
||||||
|
/// Project name (e.g. the `name` key of `Cargo.toml`).
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Project version.
|
||||||
|
pub version: Option<String>,
|
||||||
|
/// Project description.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Project homepage.
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
/// Project license (SPDX identifier).
|
||||||
|
pub license: Option<String>,
|
||||||
|
/// Installed command / binary name (e.g. the first `[[bin]]` target or
|
||||||
|
/// console script).
|
||||||
|
pub command: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<OutputFile>;
|
||||||
|
|
||||||
|
/// Extra `debian/` files beyond the common set rendered by
|
||||||
|
/// [`super::debian`] (e.g. `debian/install`).
|
||||||
|
fn debian(&self, opts: &NewOptions) -> Vec<OutputFile>;
|
||||||
|
|
||||||
|
/// Build-Depends beyond `debhelper-compat (= 13)`.
|
||||||
|
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Architecture of the binary package (`all` or `any`).
|
||||||
|
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||||
|
"all"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `dh` invocation (without leading tab) used by the `%:` target of
|
||||||
|
/// `debian/rules`. Templates needing more than the plain `dh $@` spell
|
||||||
|
/// their buildsystem/sequencer options here so the generated rules stay
|
||||||
|
/// valid make.
|
||||||
|
fn rules_dh_line(&self) -> String {
|
||||||
|
"dh $@".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lines appended to `debian/rules` after the default `dh $@` stanza
|
||||||
|
/// (e.g. `override_dh_*` targets). Must use tabs for recipe lines.
|
||||||
|
fn rules_extra(&self, _opts: &NewOptions) -> String {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra `debian/control` source-stanza fields beyond the common set
|
||||||
|
/// (e.g. `XS-Go-Import-Path`).
|
||||||
|
fn source_fields(&self, _opts: &NewOptions) -> Vec<(String, String)> {
|
||||||
|
Vec::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<ProbeResult> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hook run after the generated files have been written to `tree` and
|
||||||
|
/// before the orig tarball is created, for templates that need to run
|
||||||
|
/// host tooling over the freshly written tree (e.g. `cargo vendor`, so
|
||||||
|
/// the vendored sources land inside the tarball).
|
||||||
|
fn post_write(
|
||||||
|
&self,
|
||||||
|
_opts: &NewOptions,
|
||||||
|
_tree: &Path,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
/// Static instance of the makefile template.
|
||||||
|
pub static MAKEFILE: makefile::Makefile = makefile::Makefile;
|
||||||
|
/// Static instance of the python template.
|
||||||
|
pub static PYTHON: python::Python = python::Python;
|
||||||
|
/// Static instance of the meson template.
|
||||||
|
pub static MESON: meson::Meson = meson::Meson;
|
||||||
|
/// Static instance of the cmake template.
|
||||||
|
pub static CMAKE: cmake::Cmake = cmake::Cmake;
|
||||||
|
/// Static instance of the autotools template.
|
||||||
|
pub static AUTOTOOLS: autotools::Autotools = autotools::Autotools;
|
||||||
|
/// Static instance of the go template.
|
||||||
|
pub static GO: go::Go = go::Go;
|
||||||
|
/// Static instance of the rust template.
|
||||||
|
pub static RUST: rust::Rust = rust::Rust;
|
||||||
|
|
||||||
|
/// Every implemented template.
|
||||||
|
static TEMPLATES: &[&dyn Template] = &[
|
||||||
|
&SHELL, &EMPTY, &MAKEFILE, &PYTHON, &MESON, &CMAKE, &AUTOTOOLS, &GO, &RUST,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Look up the template implementation for `id`; `None` only if a
|
||||||
|
/// [`TemplateId`] ever grows without a registered template (callers turn
|
||||||
|
/// this into a friendly error instead of panicking).
|
||||||
|
pub fn get(id: TemplateId) -> Option<&'static dyn Template> {
|
||||||
|
match id {
|
||||||
|
TemplateId::Shell => Some(&SHELL),
|
||||||
|
TemplateId::Empty => Some(&EMPTY),
|
||||||
|
TemplateId::Makefile => Some(&MAKEFILE),
|
||||||
|
TemplateId::Python => Some(&PYTHON),
|
||||||
|
TemplateId::Meson => Some(&MESON),
|
||||||
|
TemplateId::Cmake => Some(&CMAKE),
|
||||||
|
TemplateId::Autotools => Some(&AUTOTOOLS),
|
||||||
|
TemplateId::Go => Some(&GO),
|
||||||
|
TemplateId::Rust => Some(&RUST),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every implemented template.
|
||||||
|
pub fn all() -> &'static [&'static dyn Template] {
|
||||||
|
TEMPLATES
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate `name` on `$PATH` (a tiny `which`): `None` when `PATH` is unset or
|
||||||
|
/// nothing executable-looking matches.
|
||||||
|
pub(crate) fn find_on_path(name: &str) -> Option<PathBuf> {
|
||||||
|
let path = std::env::var_os("PATH")?;
|
||||||
|
std::env::split_paths(&path)
|
||||||
|
.map(|dir| dir.join(name))
|
||||||
|
.find(|candidate| candidate.is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory whose sources are being packaged, when there is one:
|
||||||
|
/// `None` for the skeleton mode (the skeleton files are rendered in memory
|
||||||
|
/// and do not exist on disk yet).
|
||||||
|
pub(crate) fn source_dir_of(opts: &NewOptions) -> Option<PathBuf> {
|
||||||
|
match &opts.source_dir {
|
||||||
|
SourceDir::Skeleton => None,
|
||||||
|
SourceDir::Here => std::env::current_dir().ok(),
|
||||||
|
SourceDir::Path(path) => Some(path.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_covers_every_template() {
|
||||||
|
for id in TemplateId::all() {
|
||||||
|
assert!(get(id).is_some(), "{id} must be registered");
|
||||||
|
assert_eq!(get(id).unwrap().id(), id);
|
||||||
|
}
|
||||||
|
assert_eq!(all().len(), TemplateId::all().len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_defaults_to_none() {
|
||||||
|
assert!(
|
||||||
|
get(TemplateId::Shell)
|
||||||
|
.unwrap()
|
||||||
|
.probe(Path::new("/"))
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The final `debian/rules` of every template must be valid-looking
|
||||||
|
/// make: `#!/usr/bin/make -f` shebang, exactly one `%:` target whose
|
||||||
|
/// recipe is the template's dh line, tab-indented recipes only, and no
|
||||||
|
/// trailing blank lines.
|
||||||
|
#[test]
|
||||||
|
fn rules_composition_per_template() {
|
||||||
|
let o = NewOptions {
|
||||||
|
name: "mytool".into(),
|
||||||
|
template: TemplateId::Empty,
|
||||||
|
source_dir: SourceDir::Here,
|
||||||
|
upstream_version: "0.1.0".into(),
|
||||||
|
revision: 1,
|
||||||
|
summary: "A tool".into(),
|
||||||
|
long_description: "A tool".into(),
|
||||||
|
homepage: None,
|
||||||
|
license: crate::new::options::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: false,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
for id in TemplateId::all() {
|
||||||
|
let template = get(id).unwrap();
|
||||||
|
let files = super::super::debian::files(&o, template);
|
||||||
|
let rules = files
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "debian/rules")
|
||||||
|
.expect("every template renders debian/rules");
|
||||||
|
|
||||||
|
assert!(rules.executable, "{id}: rules must carry the exec bit");
|
||||||
|
assert!(
|
||||||
|
rules.contents.starts_with("#!/usr/bin/make -f\n%:\n\t"),
|
||||||
|
"{id}: rules must start with the shebang and %: target"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rules.contents.matches("\n%:\n").count() == 1,
|
||||||
|
"{id}: exactly one %: target expected"
|
||||||
|
);
|
||||||
|
// No recipe may be indented with spaces (make requires tabs).
|
||||||
|
for line in rules.contents.lines() {
|
||||||
|
assert!(
|
||||||
|
!line.starts_with(' '),
|
||||||
|
"{id}: space-indented line in rules: {line:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The template's dh line is the %: recipe.
|
||||||
|
assert!(
|
||||||
|
rules
|
||||||
|
.contents
|
||||||
|
.contains(&format!("\n%:\n\t{}\n", template.rules_dh_line())),
|
||||||
|
"{id}: %: recipe must be the dh line {:?} in {:?}",
|
||||||
|
template.rules_dh_line(),
|
||||||
|
rules.contents
|
||||||
|
);
|
||||||
|
// rule_extra targets must be declared at column 0 with tabbed
|
||||||
|
// recipes.
|
||||||
|
let extra = template.rules_extra(&o);
|
||||||
|
if !extra.is_empty() {
|
||||||
|
assert!(rules.contents.contains(&format!("\n{extra}")), "{id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-template Build-Depends / architecture / rules shape, locking the
|
||||||
|
/// table from the spec.
|
||||||
|
#[test]
|
||||||
|
fn build_depends_architecture_and_rules_table() {
|
||||||
|
let o = NewOptions {
|
||||||
|
name: "mytool".into(),
|
||||||
|
template: TemplateId::Empty,
|
||||||
|
source_dir: SourceDir::Skeleton,
|
||||||
|
upstream_version: "0.1.0".into(),
|
||||||
|
revision: 1,
|
||||||
|
summary: "A tool".into(),
|
||||||
|
long_description: "A tool".into(),
|
||||||
|
homepage: None,
|
||||||
|
license: crate::new::options::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: false,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
};
|
||||||
|
let deps = |id| {
|
||||||
|
let mut all = vec!["debhelper-compat (= 13)".to_string()];
|
||||||
|
all.extend(get(id).unwrap().build_depends(&o));
|
||||||
|
all.join(", ")
|
||||||
|
};
|
||||||
|
let arch = |id| get(id).unwrap().architecture(&o);
|
||||||
|
let dh = |id| get(id).unwrap().rules_dh_line();
|
||||||
|
|
||||||
|
assert_eq!(deps(TemplateId::Shell), "debhelper-compat (= 13)");
|
||||||
|
assert_eq!(arch(TemplateId::Shell), "all");
|
||||||
|
assert_eq!(dh(TemplateId::Shell), "dh $@");
|
||||||
|
|
||||||
|
assert_eq!(deps(TemplateId::Empty), "debhelper-compat (= 13)");
|
||||||
|
assert_eq!(arch(TemplateId::Empty), "all");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
deps(TemplateId::Makefile),
|
||||||
|
"debhelper-compat (= 13), build-essential"
|
||||||
|
);
|
||||||
|
assert_eq!(arch(TemplateId::Makefile), "any");
|
||||||
|
assert_eq!(dh(TemplateId::Makefile), "dh $@");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
dh(TemplateId::Python),
|
||||||
|
"dh $@ --with python3 --buildsystem=pybuild"
|
||||||
|
);
|
||||||
|
// Skeleton projects use the setuptools pyproject backend.
|
||||||
|
assert_eq!(
|
||||||
|
deps(TemplateId::Python),
|
||||||
|
"debhelper-compat (= 13), dh-python, python3-all, \
|
||||||
|
pybuild-plugin-pyproject, python3-setuptools"
|
||||||
|
);
|
||||||
|
assert_eq!(arch(TemplateId::Python), "all");
|
||||||
|
|
||||||
|
assert_eq!(deps(TemplateId::Meson), "debhelper-compat (= 13), meson");
|
||||||
|
assert_eq!(arch(TemplateId::Meson), "any");
|
||||||
|
assert_eq!(dh(TemplateId::Meson), "dh $@ --buildsystem=meson");
|
||||||
|
|
||||||
|
assert_eq!(deps(TemplateId::Cmake), "debhelper-compat (= 13), cmake");
|
||||||
|
assert_eq!(arch(TemplateId::Cmake), "any");
|
||||||
|
assert_eq!(dh(TemplateId::Cmake), "dh $@ --buildsystem=cmake");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
deps(TemplateId::Autotools),
|
||||||
|
"debhelper-compat (= 13), autoconf, automake, libtool"
|
||||||
|
);
|
||||||
|
assert_eq!(arch(TemplateId::Autotools), "any");
|
||||||
|
assert_eq!(dh(TemplateId::Autotools), "dh $@");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
deps(TemplateId::Go),
|
||||||
|
"debhelper-compat (= 13), golang-any, dh-golang"
|
||||||
|
);
|
||||||
|
assert_eq!(arch(TemplateId::Go), "any");
|
||||||
|
assert_eq!(dh(TemplateId::Go), "dh $@ --buildsystem=golang");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
deps(TemplateId::Rust),
|
||||||
|
"debhelper-compat (= 13), cargo:native, rustc:native"
|
||||||
|
);
|
||||||
|
assert_eq!(arch(TemplateId::Rust), "any");
|
||||||
|
assert_eq!(dh(TemplateId::Rust), "dh $@");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
//! The `python` template: a PEP 517 project built with pybuild.
|
||||||
|
//!
|
||||||
|
//! The PEP 517 backend is read from `pyproject.toml` (`build-backend =
|
||||||
|
//! …`) with a deliberately minimal line-oriented reader (see
|
||||||
|
//! [`read_pyproject`]) — pkh has no TOML dependency, and only a handful of
|
||||||
|
//! keys matter here. A bare `setup.py`/`setup.cfg` project falls back to
|
||||||
|
//! setuptools without the `pybuild-plugin-pyproject` helper.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
use super::{OutputFile, ProbeResult, Template, source_dir_of};
|
||||||
|
use crate::new::options::{NewOptions, TemplateId};
|
||||||
|
|
||||||
|
/// Python project (`pyproject.toml` / `setup.py` / `setup.cfg`).
|
||||||
|
pub struct Python;
|
||||||
|
|
||||||
|
/// The PEP 517 backend of a project.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Backend {
|
||||||
|
/// `setuptools.build_meta` (also the PEP 517 default when
|
||||||
|
/// `build-backend` is missing).
|
||||||
|
Setuptools,
|
||||||
|
/// `poetry.core.masonry.api`
|
||||||
|
Poetry,
|
||||||
|
/// `hatchling.build`
|
||||||
|
Hatchling,
|
||||||
|
/// `flit_core.buildapi` (and any other `flit_core` backend)
|
||||||
|
Flit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend {
|
||||||
|
/// The Debian package providing this backend.
|
||||||
|
fn package(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Backend::Setuptools => "python3-setuptools",
|
||||||
|
Backend::Poetry => "python3-poetry-core",
|
||||||
|
Backend::Hatchling => "python3-hatchling",
|
||||||
|
Backend::Flit => "python3-flit-core",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map the raw `build-backend` value to the backend enum; unknown values
|
||||||
|
/// fall back to setuptools (the PEP 517 default tooling answer).
|
||||||
|
fn backend_of_value(value: &str) -> Backend {
|
||||||
|
if value.starts_with("poetry") {
|
||||||
|
Backend::Poetry
|
||||||
|
} else if value.starts_with("hatchling") {
|
||||||
|
Backend::Hatchling
|
||||||
|
} else if value.starts_with("flit_core") {
|
||||||
|
Backend::Flit
|
||||||
|
} else {
|
||||||
|
// setuptools.build_meta, setuptools.build_meta:__legacy__, unknown.
|
||||||
|
Backend::Setuptools
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the packaged project is pyproject-based (skeletons always are:
|
||||||
|
/// the generated skeleton carries a `pyproject.toml`), which decides whether
|
||||||
|
/// `pybuild-plugin-pyproject` is needed in Build-Depends.
|
||||||
|
fn uses_pyproject(opts: &NewOptions) -> bool {
|
||||||
|
match source_dir_of(opts) {
|
||||||
|
Some(dir) => dir.join("pyproject.toml").exists(),
|
||||||
|
// Skeleton mode renders a pyproject.toml.
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The backend of the packaged project: read from `pyproject.toml` when
|
||||||
|
/// there is one (missing `build-backend` = the setuptools default), falling
|
||||||
|
/// back to setuptools for bare `setup.py`/`setup.cfg` projects. Skeleton
|
||||||
|
/// mode uses setuptools (the generated backend).
|
||||||
|
fn backend(opts: &NewOptions) -> Backend {
|
||||||
|
match source_dir_of(opts) {
|
||||||
|
Some(dir) => read_pyproject(&dir.join("pyproject.toml"))
|
||||||
|
.and_then(|project| project.backend)
|
||||||
|
.map(|value| backend_of_value(&value))
|
||||||
|
.unwrap_or(Backend::Setuptools),
|
||||||
|
None => Backend::Setuptools,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid Python identifier derived from the package name: dpkg names may
|
||||||
|
/// carry `+`/`.` and may start with a digit, none of which a module name
|
||||||
|
/// may.
|
||||||
|
fn module_name(opts: &NewOptions) -> String {
|
||||||
|
let mut module = opts.name.replace(['-', '.', '+'], "_");
|
||||||
|
if module.starts_with(|c: char| c.is_ascii_digit()) {
|
||||||
|
module = format!("_{module}");
|
||||||
|
}
|
||||||
|
module
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the project hints at compiled C extensions (pyo3 in Cargo.toml,
|
||||||
|
/// `ext_modules` / `Extension` imports in setup.py): those need
|
||||||
|
/// `Architecture: any` + `python3-all-dev` instead of `Architecture: all`.
|
||||||
|
fn c_extension_hints(opts: &NewOptions) -> bool {
|
||||||
|
let Some(dir) = source_dir_of(opts) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if let Ok(cargo) = std::fs::read_to_string(dir.join("Cargo.toml"))
|
||||||
|
&& cargo.contains("pyo3")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Ok(setup) = std::fs::read_to_string(dir.join("setup.py"))
|
||||||
|
&& (setup.contains("ext_modules") || setup.contains("from setuptools import Extension"))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Template for Python {
|
||||||
|
fn id(&self) -> TemplateId {
|
||||||
|
TemplateId::Python
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal setuptools-based `pyproject.toml` with one console script,
|
||||||
|
/// plus the one-module package providing it.
|
||||||
|
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
let module = module_name(opts);
|
||||||
|
vec![
|
||||||
|
OutputFile::new(
|
||||||
|
"pyproject.toml",
|
||||||
|
format!(
|
||||||
|
"[build-system]\n\
|
||||||
|
requires = [\"setuptools\"]\n\
|
||||||
|
build-backend = \"setuptools.build_meta\"\n\
|
||||||
|
\n\
|
||||||
|
[project]\n\
|
||||||
|
name = \"{name}\"\n\
|
||||||
|
version = \"{version}\"\n\
|
||||||
|
description = \"{summary}\"\n\
|
||||||
|
requires-python = \">=3.8\"\n\
|
||||||
|
\n\
|
||||||
|
[project.scripts]\n\
|
||||||
|
{command} = \"{module}:main\"\n",
|
||||||
|
name = opts.name,
|
||||||
|
version = opts.upstream_version,
|
||||||
|
summary = opts.summary,
|
||||||
|
command = opts.command,
|
||||||
|
module = module,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OutputFile::new(
|
||||||
|
format!("{module}/__init__.py"),
|
||||||
|
format!(
|
||||||
|
"\"\"\"Placeholder for {name}, generated by `pkh new`.\"\"\"\n\
|
||||||
|
\n\
|
||||||
|
\n\
|
||||||
|
def main() -> None:\n\
|
||||||
|
\x20 print(\"Hello from {command}!\")\n",
|
||||||
|
name = opts.name,
|
||||||
|
command = opts.command,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No extra debian/ files: pybuild installs the package and its console
|
||||||
|
/// entry points (under `/usr/bin`) automatically.
|
||||||
|
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
|
||||||
|
let mut deps = vec!["dh-python".to_string(), "python3-all".to_string()];
|
||||||
|
if c_extension_hints(opts) {
|
||||||
|
deps.push("python3-all-dev".to_string());
|
||||||
|
}
|
||||||
|
if uses_pyproject(opts) {
|
||||||
|
deps.push("pybuild-plugin-pyproject".to_string());
|
||||||
|
}
|
||||||
|
deps.push(backend(opts).package().to_string());
|
||||||
|
deps
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `all` unless the project hints at compiled C extensions.
|
||||||
|
fn architecture(&self, opts: &NewOptions) -> &'static str {
|
||||||
|
if c_extension_hints(opts) {
|
||||||
|
"any"
|
||||||
|
} else {
|
||||||
|
"all"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rules_dh_line(&self) -> String {
|
||||||
|
"dh $@ --with python3 --buildsystem=pybuild".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_fields(&self, _opts: &NewOptions) -> Vec<(String, String)> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata from the `[project]` section of `pyproject.toml` (name,
|
||||||
|
/// version, description, homepage, license, first console script), with
|
||||||
|
/// a minimal `setup.py` `name=…` fallback.
|
||||||
|
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
||||||
|
let project = read_pyproject(&dir.join("pyproject.toml"));
|
||||||
|
if let Some(project) = project {
|
||||||
|
let result = ProbeResult {
|
||||||
|
name: project.name,
|
||||||
|
version: project.version,
|
||||||
|
description: project.description,
|
||||||
|
homepage: project.homepage,
|
||||||
|
license: project.license,
|
||||||
|
command: project.script,
|
||||||
|
};
|
||||||
|
if result.name.is_some()
|
||||||
|
|| result.version.is_some()
|
||||||
|
|| result.description.is_some()
|
||||||
|
|| result.command.is_some()
|
||||||
|
{
|
||||||
|
return Some(result);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare setup.py: catch the `name='…'` argument only.
|
||||||
|
let setup = std::fs::read_to_string(dir.join("setup.py")).ok()?;
|
||||||
|
static NAME_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||||
|
let regex =
|
||||||
|
NAME_REGEX.get_or_init(|| Regex::new(r#"name\s*=\s*["']([^"']+)["']"#).unwrap());
|
||||||
|
let name = regex.captures(&setup)?.get(1)?.as_str().to_string();
|
||||||
|
Some(ProbeResult {
|
||||||
|
name: Some(name),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The few `pyproject.toml` keys pkh new cares about.
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq, Clone)]
|
||||||
|
pub struct PyProject {
|
||||||
|
/// Raw `build-backend` value of `[build-system]`.
|
||||||
|
pub backend: Option<String>,
|
||||||
|
/// `name` of `[project]`.
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// `version` of `[project]`.
|
||||||
|
pub version: Option<String>,
|
||||||
|
/// `description` of `[project]`.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// `Homepage` of `[project.urls]`.
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
/// `license` of `[project]` (quoted-string form).
|
||||||
|
pub license: Option<String>,
|
||||||
|
/// First console-script key of `[project.scripts]`.
|
||||||
|
pub script: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal line-oriented reader for the `pyproject.toml` keys pkh new needs:
|
||||||
|
/// it tracks the current `[section]` header, matches `key = value` pairs at
|
||||||
|
/// the start of a line and tolerates quotes and comments. It is not a TOML
|
||||||
|
/// parser — anything it cannot understand is simply ignored and the caller
|
||||||
|
/// falls back to the defaults (pkh has no TOML dependency, and a full parser
|
||||||
|
/// would be out of proportion for a handful of keys).
|
||||||
|
pub fn read_pyproject(path: &Path) -> Option<PyProject> {
|
||||||
|
let content = std::fs::read_to_string(path).ok()?;
|
||||||
|
let mut project = PyProject::default();
|
||||||
|
let mut section = String::new();
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||||
|
section = header.trim().to_string();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((key, value)) = split_key_value(line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match (section.as_str(), key) {
|
||||||
|
("build-system", "build-backend") => project.backend = Some(value),
|
||||||
|
("project", "name") => project.name = Some(value),
|
||||||
|
("project", "version") => project.version = Some(value),
|
||||||
|
("project", "description") => project.description = Some(value),
|
||||||
|
("project", "license") => project.license = license_text(&value),
|
||||||
|
("project.urls", "Homepage") => project.homepage = Some(value),
|
||||||
|
("project.scripts", key) => {
|
||||||
|
if project.script.is_none() {
|
||||||
|
project.script = Some(key.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if project == PyProject::default() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The license out of a raw `license =` value: plain (unquoted by
|
||||||
|
/// [`split_key_value`]) strings pass through, while the PEP 621 inline-table
|
||||||
|
/// form `{text = "…"}` yields its `text` key. Anything else (other table
|
||||||
|
/// forms) is ignored.
|
||||||
|
fn license_text(value: &str) -> Option<String> {
|
||||||
|
if value.starts_with('{') {
|
||||||
|
static TEXT_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||||
|
let regex = TEXT_REGEX.get_or_init(|| Regex::new(r#"text\s*=\s*"([^"]+)""#).unwrap());
|
||||||
|
Some(regex.captures(value)?.get(1)?.as_str().to_string())
|
||||||
|
} else {
|
||||||
|
Some(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a `key = value` line into its unquoted parts, ignoring inline
|
||||||
|
/// comments outside of the (first) quoted string. Multi-line values (arrays,
|
||||||
|
/// tables) are not supported on purpose: they never carry the keys read
|
||||||
|
/// here.
|
||||||
|
fn split_key_value(line: &str) -> Option<(&str, String)> {
|
||||||
|
let (key, value) = line.split_once('=')?;
|
||||||
|
let key = key.trim();
|
||||||
|
if key.is_empty() || key.contains(' ') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value = value.trim();
|
||||||
|
// Quoted string: strip the quotes; a '#' inside the quotes is literal.
|
||||||
|
let value = if let Some(rest) = value.strip_prefix('"') {
|
||||||
|
rest.split_once('"')?.0.to_string()
|
||||||
|
} else if let Some(rest) = value.strip_prefix('\'') {
|
||||||
|
rest.split_once('\'')?.0.to_string()
|
||||||
|
} else {
|
||||||
|
// Bare value (e.g. true, 5, or {…} tables): cut the inline comment.
|
||||||
|
match value.split_once('#') {
|
||||||
|
Some((bare, _)) => bare.trim().to_string(),
|
||||||
|
None => value.to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some((key, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::new::options::{License, SourceDir};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn opts(source_dir: SourceDir) -> NewOptions {
|
||||||
|
NewOptions {
|
||||||
|
name: "mytool".into(),
|
||||||
|
template: TemplateId::Python,
|
||||||
|
source_dir,
|
||||||
|
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,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_opts(dir: &Path) -> NewOptions {
|
||||||
|
opts(SourceDir::Path(dir.to_path_buf()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn python_skeleton_shape() {
|
||||||
|
let o = opts(SourceDir::Skeleton);
|
||||||
|
let template = super::super::get(TemplateId::Python).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(template.architecture(&o), "all");
|
||||||
|
assert!(template.debian(&o).is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
template.rules_dh_line(),
|
||||||
|
"dh $@ --with python3 --buildsystem=pybuild"
|
||||||
|
);
|
||||||
|
|
||||||
|
let skeleton = template.skeleton(&o);
|
||||||
|
let pyproject = skeleton
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "pyproject.toml")
|
||||||
|
.expect("pyproject skeleton");
|
||||||
|
assert!(
|
||||||
|
pyproject
|
||||||
|
.contents
|
||||||
|
.contains("build-backend = \"setuptools.build_meta\"")
|
||||||
|
);
|
||||||
|
assert!(pyproject.contents.contains("name = \"mytool\""));
|
||||||
|
assert!(pyproject.contents.contains("mytool = \"mytool:main\""));
|
||||||
|
let module = skeleton
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "mytool/__init__.py")
|
||||||
|
.expect("module skeleton");
|
||||||
|
assert!(module.contents.contains("def main()"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// dpkg names may carry `+`/`.` and start with a digit — none of which a
|
||||||
|
/// Python module name may.
|
||||||
|
#[test]
|
||||||
|
fn module_name_is_a_valid_python_identifier() {
|
||||||
|
let o = NewOptions {
|
||||||
|
name: "9x.tool+".into(),
|
||||||
|
..opts(SourceDir::Skeleton)
|
||||||
|
};
|
||||||
|
assert_eq!(module_name(&o), "_9x_tool_");
|
||||||
|
|
||||||
|
let o = NewOptions {
|
||||||
|
name: "my-tool".into(),
|
||||||
|
..opts(SourceDir::Skeleton)
|
||||||
|
};
|
||||||
|
assert_eq!(module_name(&o), "my_tool");
|
||||||
|
|
||||||
|
// The skeleton module path and the pyproject script agree.
|
||||||
|
let template = super::super::get(TemplateId::Python).unwrap();
|
||||||
|
let skeleton = template.skeleton(&o);
|
||||||
|
assert!(skeleton.iter().any(|f| f.path == "my_tool/__init__.py"));
|
||||||
|
assert!(skeleton.iter().any(|f| {
|
||||||
|
f.path == "pyproject.toml" && f.contents.contains("mytool = \"my_tool:main\"")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The backend-detection table: `build-backend` value → Debian package,
|
||||||
|
/// plus the missing-file/missing-key/bare-setup.py defaults.
|
||||||
|
#[test]
|
||||||
|
fn backend_detection_table() {
|
||||||
|
// Raw value mapping.
|
||||||
|
for (value, expected) in [
|
||||||
|
("setuptools.build_meta", Backend::Setuptools),
|
||||||
|
("setuptools.build_meta:__legacy__", Backend::Setuptools),
|
||||||
|
("poetry.core.masonry.api", Backend::Poetry),
|
||||||
|
("poetry.core.masonry.api.something", Backend::Poetry),
|
||||||
|
("hatchling.build", Backend::Hatchling),
|
||||||
|
("flit_core.buildapi", Backend::Flit),
|
||||||
|
("flit_core.wheel", Backend::Flit),
|
||||||
|
] {
|
||||||
|
assert_eq!(backend_of_value(value), expected, "{value}");
|
||||||
|
}
|
||||||
|
// Unknown values fall back to setuptools.
|
||||||
|
assert_eq!(backend_of_value("mystery.backend"), Backend::Setuptools);
|
||||||
|
|
||||||
|
let template = super::super::get(TemplateId::Python).unwrap();
|
||||||
|
|
||||||
|
// No source dir (skeleton): setuptools + pyproject plugin.
|
||||||
|
assert_eq!(backend(&opts(SourceDir::Skeleton)), Backend::Setuptools);
|
||||||
|
assert!(uses_pyproject(&opts(SourceDir::Skeleton)));
|
||||||
|
|
||||||
|
// pyproject.toml with each backend, [build-system] section-aware.
|
||||||
|
let cases = [
|
||||||
|
("setuptools.build_meta", "python3-setuptools"),
|
||||||
|
("poetry.core.masonry.api", "python3-poetry-core"),
|
||||||
|
("hatchling.build", "python3-hatchling"),
|
||||||
|
("flit_core.buildapi", "python3-flit-core"),
|
||||||
|
];
|
||||||
|
for (value, package) in cases {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("pyproject.toml"),
|
||||||
|
format!(
|
||||||
|
"[other-section]\nbuild-backend = \"ignored\"\n\n\
|
||||||
|
[build-system]\nrequires = [\"x\"]\n\
|
||||||
|
build-backend = \"{value}\"\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let o = source_opts(dir.path());
|
||||||
|
assert_eq!(backend(&o), backend_of_value(value), "{value}");
|
||||||
|
let deps = template.build_depends(&o);
|
||||||
|
assert!(deps.contains(&package.to_string()), "{deps:?}");
|
||||||
|
assert!(deps.contains(&"pybuild-plugin-pyproject".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// pyproject.toml without build-backend: setuptools (PEP 517 default).
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("pyproject.toml"),
|
||||||
|
"[project]\nname = \"x\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let o = source_opts(dir.path());
|
||||||
|
assert_eq!(backend(&o), Backend::Setuptools);
|
||||||
|
|
||||||
|
// Bare setup.py: setuptools WITHOUT the pyproject plugin.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("setup.py"),
|
||||||
|
"from setuptools import setup\nsetup()\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let o = source_opts(dir.path());
|
||||||
|
assert!(!uses_pyproject(&o));
|
||||||
|
let deps = template.build_depends(&o);
|
||||||
|
assert!(
|
||||||
|
!deps.contains(&"pybuild-plugin-pyproject".to_string()),
|
||||||
|
"{deps:?}"
|
||||||
|
);
|
||||||
|
assert!(deps.contains(&"python3-setuptools".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn c_extension_hints_flip_architecture_and_deps() {
|
||||||
|
let template = super::super::get(TemplateId::Python).unwrap();
|
||||||
|
|
||||||
|
// pyo3 in Cargo.toml.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("Cargo.toml"),
|
||||||
|
"[dependencies]\npyo3 = \"0.22\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let o = source_opts(dir.path());
|
||||||
|
assert!(c_extension_hints(&o));
|
||||||
|
let deps = template.build_depends(&o);
|
||||||
|
assert!(deps.contains(&"python3-all-dev".to_string()), "{deps:?}");
|
||||||
|
|
||||||
|
// ext_modules in setup.py.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("setup.py"),
|
||||||
|
"from setuptools import setup, Extension\nsetup(ext_modules=[])\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let o = source_opts(dir.path());
|
||||||
|
assert!(c_extension_hints(&o));
|
||||||
|
|
||||||
|
// Nothing relevant: no hints.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("pyproject.toml"), "[project]\n").unwrap();
|
||||||
|
let o = source_opts(dir.path());
|
||||||
|
assert!(!c_extension_hints(&o));
|
||||||
|
assert_eq!(template.build_depends(&o).len(), 4); // dh-python, python3-all, plugin, setuptools
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_reads_project_and_scripts() {
|
||||||
|
let template = super::super::get(TemplateId::Python).unwrap();
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("pyproject.toml"),
|
||||||
|
"[build-system]\n\
|
||||||
|
build-backend = \"hatchling.build\" # inline comment\n\
|
||||||
|
\n\
|
||||||
|
[project]\n\
|
||||||
|
name = \"my-tool\"\n\
|
||||||
|
version = \"1.2.3\"\n\
|
||||||
|
description = \"Does things\"\n\
|
||||||
|
license = \"MIT\"\n\
|
||||||
|
\n\
|
||||||
|
[project.urls]\n\
|
||||||
|
Homepage = \"https://example.com/my-tool\"\n\
|
||||||
|
\n\
|
||||||
|
[project.scripts]\n\
|
||||||
|
mycli = \"my_tool.cli:main\"\n\
|
||||||
|
other = \"my_tool.other:run\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("my-tool"));
|
||||||
|
assert_eq!(probe.version.as_deref(), Some("1.2.3"));
|
||||||
|
assert_eq!(probe.description.as_deref(), Some("Does things"));
|
||||||
|
assert_eq!(probe.license.as_deref(), Some("MIT"));
|
||||||
|
assert_eq!(
|
||||||
|
probe.homepage.as_deref(),
|
||||||
|
Some("https://example.com/my-tool")
|
||||||
|
);
|
||||||
|
// First [project.scripts] key becomes the command.
|
||||||
|
assert_eq!(probe.command.as_deref(), Some("mycli"));
|
||||||
|
|
||||||
|
// Bare setup.py: the name= argument.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("setup.py"),
|
||||||
|
"setup(\n name='legacy-tool',\n version=\"9.9\",\n)\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("legacy-tool"));
|
||||||
|
assert_eq!(probe.version, None);
|
||||||
|
|
||||||
|
// Nothing readable: silent None.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
assert!(template.probe(dir.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pyproject_reader_is_section_aware_and_tolerant() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("pyproject.toml");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
"# leading comment\n\
|
||||||
|
[tool.black]\n\
|
||||||
|
name = \"should not leak\"\n\
|
||||||
|
\n\
|
||||||
|
[project]\n\
|
||||||
|
name = \"quoted-name\"\n\
|
||||||
|
description = 'single quoted'\n\
|
||||||
|
version = \"1.0\" # trailing comment\n\
|
||||||
|
license = {text = \"MIT\"}\n\
|
||||||
|
requires-python = \">=3.8\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let project = read_pyproject(&path).unwrap();
|
||||||
|
assert_eq!(project.name.as_deref(), Some("quoted-name"));
|
||||||
|
assert_eq!(project.description.as_deref(), Some("single quoted"));
|
||||||
|
assert_eq!(project.version.as_deref(), Some("1.0"));
|
||||||
|
// The PEP 621 inline-table license form yields its text key.
|
||||||
|
assert_eq!(project.license.as_deref(), Some("MIT"));
|
||||||
|
|
||||||
|
// Missing file.
|
||||||
|
assert!(read_pyproject(&dir.path().join("missing.toml")).is_none());
|
||||||
|
|
||||||
|
// Only comments: no project.
|
||||||
|
std::fs::write(&path, "# nothing\n").unwrap();
|
||||||
|
assert_eq!(read_pyproject(&path), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
//! The `rust` template: a Cargo project shipped as a **vendored** build.
|
||||||
|
//!
|
||||||
|
//! Standard Debian practice (debcargo → dh-cargo → registry deps) needs every
|
||||||
|
//! Cargo dependency as a `librust-*-dev` archive package — unusable for a
|
||||||
|
//! brand-new program. v1 therefore vendors at scaffold time: `cargo vendor`
|
||||||
|
//! runs over the freshly written tree (before the orig tarball is created, so
|
||||||
|
//! `vendor/` travels inside it), and `debian/rules` builds offline with the
|
||||||
|
//! source replacement. When host `cargo` is missing or vendoring fails, the
|
||||||
|
//! scaffold continues with a loud warning — the package will not build until
|
||||||
|
//! the user vendors manually.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::{OutputFile, ProbeResult, Template, find_on_path, source_dir_of};
|
||||||
|
use crate::new::options::{NewOptions, SourceDir, TemplateId};
|
||||||
|
|
||||||
|
/// Rust project (`Cargo.toml`).
|
||||||
|
pub struct Rust;
|
||||||
|
|
||||||
|
/// The source-replacement configuration, used when `cargo vendor` did not
|
||||||
|
/// print one itself (old cargo versions, empty output).
|
||||||
|
const FALLBACK_VENDOR_CONFIG: &str = "[source.crates-io]\nreplace-with = \"vendored-sources\"\n\n[source.vendored-sources]\ndirectory = \"vendor\"";
|
||||||
|
|
||||||
|
/// The cargo crate name of the skeleton: dpkg package names may carry `+`
|
||||||
|
/// or `.`, which cargo rejects in package names.
|
||||||
|
fn crate_name(opts: &NewOptions) -> String {
|
||||||
|
opts.name.replace(['+', '.'], "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Template for Rust {
|
||||||
|
fn id(&self) -> TemplateId {
|
||||||
|
TemplateId::Rust
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A zero-dependency `Cargo.toml` and the matching `src/main.rs`.
|
||||||
|
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
vec![
|
||||||
|
OutputFile::new(
|
||||||
|
"Cargo.toml",
|
||||||
|
format!(
|
||||||
|
"[package]\n\
|
||||||
|
name = \"{name}\"\n\
|
||||||
|
version = \"{version}\"\n\
|
||||||
|
edition = \"2021\"\n\
|
||||||
|
\n\
|
||||||
|
[dependencies]\n",
|
||||||
|
name = crate_name(opts),
|
||||||
|
version = opts.upstream_version,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OutputFile::new(
|
||||||
|
"src/main.rs",
|
||||||
|
format!(
|
||||||
|
"// Placeholder for {name}, generated by `pkh new`.\n\
|
||||||
|
fn main() {{\n\
|
||||||
|
\tprintln!(\"Hello from {command}!\");\n\
|
||||||
|
}}\n",
|
||||||
|
name = opts.name,
|
||||||
|
command = opts.command,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No extra debian/ files: the vendored build lives entirely in the
|
||||||
|
/// rules overrides.
|
||||||
|
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
|
||||||
|
vec!["cargo:native".to_string(), "rustc:native".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||||
|
"any"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The vendored build overrides. `--locked` is used only when the
|
||||||
|
/// packaged tree already carries a `Cargo.lock` (fresh skeletons have
|
||||||
|
/// none yet); omitting it is always safe. The built artifact of a
|
||||||
|
/// skeleton is named after its crate (a sanitized package name) and
|
||||||
|
/// installed under the command name.
|
||||||
|
fn rules_extra(&self, opts: &NewOptions) -> String {
|
||||||
|
let locked = if lockfile_present(opts) {
|
||||||
|
" --locked"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
// The skeleton's cargo artifact is the crate name; for an existing
|
||||||
|
// project the (probed or answered) command names the binary.
|
||||||
|
let artifact = match opts.source_dir {
|
||||||
|
SourceDir::Skeleton => crate_name(opts),
|
||||||
|
_ => opts.command.clone(),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"override_dh_auto_build:\n\
|
||||||
|
\tcargo build --release --offline{locked}\n\
|
||||||
|
\n\
|
||||||
|
override_dh_auto_install:\n\
|
||||||
|
\tinstall -Dm755 target/release/{artifact} debian/{name}/usr/bin/{command}\n\
|
||||||
|
\n\
|
||||||
|
override_dh_auto_test:\n\
|
||||||
|
\tcargo test --release --offline{locked}\n\
|
||||||
|
\n\
|
||||||
|
override_dh_auto_clean:\n\
|
||||||
|
\tcargo clean\n",
|
||||||
|
locked = locked,
|
||||||
|
artifact = artifact,
|
||||||
|
command = opts.command,
|
||||||
|
name = opts.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name, version, description, homepage, license and first binary from
|
||||||
|
/// `cargo metadata --no-deps` (when host cargo is available), with a
|
||||||
|
/// minimal line-parse of `Cargo.toml` as fallback.
|
||||||
|
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
||||||
|
if let Some(result) = probe_cargo_metadata(dir) {
|
||||||
|
return Some(result);
|
||||||
|
}
|
||||||
|
probe_cargo_toml(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vendor the Cargo dependencies into the freshly written tree: run
|
||||||
|
/// `cargo vendor` in it and write `.cargo/config.toml` with the printed
|
||||||
|
/// source replacement plus `offline = true`, so the build never touches
|
||||||
|
/// the network. Failures warn loudly and continue: the scaffold stays in
|
||||||
|
/// place, the package just will not build until vendored manually.
|
||||||
|
fn post_write(
|
||||||
|
&self,
|
||||||
|
_opts: &NewOptions,
|
||||||
|
tree: &Path,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if !tree.join("Cargo.toml").exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some(cargo) = find_on_path("cargo") else {
|
||||||
|
log::warn!(
|
||||||
|
"cargo was not found on PATH: the Rust package will NOT build \
|
||||||
|
until its dependencies are vendored. Run `cargo vendor` in the \
|
||||||
|
tree and add the printed source replacement to \
|
||||||
|
.cargo/config.toml (with `[net] offline = true`)."
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!("Vendoring Cargo dependencies (`cargo vendor`) — needs one network sync");
|
||||||
|
match std::process::Command::new(&cargo)
|
||||||
|
.arg("vendor")
|
||||||
|
.current_dir(tree)
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
Ok(output) if output.status.success() => {
|
||||||
|
let printed = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let snippet = if printed.contains("[source.") {
|
||||||
|
printed
|
||||||
|
} else {
|
||||||
|
FALLBACK_VENDOR_CONFIG.to_string()
|
||||||
|
};
|
||||||
|
let config_path = tree.join(".cargo/config.toml");
|
||||||
|
if config_path.exists() {
|
||||||
|
log::warn!(
|
||||||
|
"'{}' already exists: the vendored-source replacement \
|
||||||
|
printed by `cargo vendor` was NOT written there; add it \
|
||||||
|
manually (plus `[net] offline = true`).",
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(config_path.parent().unwrap_or(tree))?;
|
||||||
|
std::fs::write(
|
||||||
|
&config_path,
|
||||||
|
format!("{snippet}\n\n[net]\noffline = true\n"),
|
||||||
|
)?;
|
||||||
|
log::info!(
|
||||||
|
"Vendored sources and '{}' written; the package builds \
|
||||||
|
fully offline",
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(output) => {
|
||||||
|
log::warn!(
|
||||||
|
"`cargo vendor` failed ({}): the package will NOT build until \
|
||||||
|
its dependencies are vendored. Run `cargo vendor` in the tree \
|
||||||
|
and add the printed source replacement to .cargo/config.toml. \
|
||||||
|
Last stderr line: {}",
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
.lines()
|
||||||
|
.last()
|
||||||
|
.unwrap_or("(no output)")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"could not run `cargo vendor` ({e}): the package will NOT \
|
||||||
|
build until its dependencies are vendored. Run \
|
||||||
|
`cargo vendor` in the tree and add the printed source \
|
||||||
|
replacement to .cargo/config.toml."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the packaged tree carries a `Cargo.lock` (skeletons do not yet).
|
||||||
|
fn lockfile_present(opts: &NewOptions) -> bool {
|
||||||
|
source_dir_of(opts).is_some_and(|dir| dir.join("Cargo.lock").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe through `cargo metadata --no-deps --format-version 1`: silent `None`
|
||||||
|
/// when cargo is unavailable or fails.
|
||||||
|
fn probe_cargo_metadata(dir: &Path) -> Option<ProbeResult> {
|
||||||
|
let cargo = find_on_path("cargo")?;
|
||||||
|
let output = std::process::Command::new(cargo)
|
||||||
|
.args(["metadata", "--no-deps", "--format-version", "1"])
|
||||||
|
.current_dir(dir)
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let metadata: Value = serde_json::from_slice(&output.stdout).ok()?;
|
||||||
|
let package = metadata.get("packages")?.as_array()?.first()?;
|
||||||
|
let command = package
|
||||||
|
.get("targets")?
|
||||||
|
.as_array()?
|
||||||
|
.iter()
|
||||||
|
.find(|target| {
|
||||||
|
target
|
||||||
|
.get("kind")
|
||||||
|
.and_then(|kind| kind.as_array())
|
||||||
|
.is_some_and(|kinds| kinds.iter().any(|k| k.as_str() == Some("bin")))
|
||||||
|
})
|
||||||
|
.and_then(|target| target.get("name"))
|
||||||
|
.and_then(|name| name.as_str())
|
||||||
|
.map(str::to_string);
|
||||||
|
let field = |key: &str| {
|
||||||
|
package
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
};
|
||||||
|
Some(ProbeResult {
|
||||||
|
name: field("name"),
|
||||||
|
version: field("version"),
|
||||||
|
description: field("description"),
|
||||||
|
homepage: field("homepage"),
|
||||||
|
license: field("license"),
|
||||||
|
command,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal line-parse fallback for `Cargo.toml` (no TOML dependency): the
|
||||||
|
/// `key = value` pairs of the `[package]` section.
|
||||||
|
fn probe_cargo_toml(dir: &Path) -> Option<ProbeResult> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;
|
||||||
|
let mut result = ProbeResult::default();
|
||||||
|
let mut in_package = false;
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||||
|
in_package = header.trim() == "package";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !in_package {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((key, value)) = line.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = value.trim().trim_matches('"').trim_matches('\'').trim();
|
||||||
|
match key.trim() {
|
||||||
|
"name" => result.name = Some(value.to_string()),
|
||||||
|
"version" => result.version = Some(value.to_string()),
|
||||||
|
"description" => result.description = Some(value.to_string()),
|
||||||
|
"homepage" => result.homepage = Some(value.to_string()),
|
||||||
|
"license" => result.license = Some(value.to_string()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.name.is_none() && result.version.is_none() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::new::options::{License, SourceDir};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn opts(source_dir: SourceDir) -> NewOptions {
|
||||||
|
NewOptions {
|
||||||
|
name: "mytool".into(),
|
||||||
|
template: TemplateId::Rust,
|
||||||
|
source_dir,
|
||||||
|
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,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rust_template_shape() {
|
||||||
|
let o = opts(SourceDir::Skeleton);
|
||||||
|
let template = super::super::get(TemplateId::Rust).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(template.architecture(&o), "any");
|
||||||
|
assert_eq!(
|
||||||
|
template.build_depends(&o),
|
||||||
|
vec!["cargo:native".to_string(), "rustc:native".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(template.rules_dh_line(), "dh $@");
|
||||||
|
assert!(template.debian(&o).is_empty());
|
||||||
|
|
||||||
|
// Skeleton: Cargo.toml + src/main.rs.
|
||||||
|
let skeleton = template.skeleton(&o);
|
||||||
|
assert!(
|
||||||
|
skeleton
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.path == "Cargo.toml" && f.contents.contains("name = \"mytool\""))
|
||||||
|
);
|
||||||
|
assert!(skeleton.iter().any(|f| f.path == "src/main.rs"));
|
||||||
|
|
||||||
|
// Fresh skeleton: no Cargo.lock, so no --locked flag anywhere.
|
||||||
|
let extra = template.rules_extra(&o);
|
||||||
|
assert!(extra.contains("override_dh_auto_build:\n\tcargo build --release --offline\n"));
|
||||||
|
assert!(
|
||||||
|
extra.contains("\tinstall -Dm755 target/release/mytool debian/mytool/usr/bin/mytool")
|
||||||
|
);
|
||||||
|
assert!(extra.contains("override_dh_auto_test:\n\tcargo test --release --offline\n"));
|
||||||
|
assert!(extra.contains("override_dh_auto_clean:\n\tcargo clean"));
|
||||||
|
assert!(!extra.contains("--locked"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rules_use_locked_only_with_lockfile() {
|
||||||
|
let template = super::super::get(TemplateId::Rust).unwrap();
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
|
||||||
|
let with_lock = opts(SourceDir::Path(dir.path().to_path_buf()));
|
||||||
|
assert!(!lockfile_present(&with_lock));
|
||||||
|
let extra = template.rules_extra(&with_lock);
|
||||||
|
assert!(!extra.contains("--locked"), "{extra}");
|
||||||
|
|
||||||
|
std::fs::write(dir.path().join("Cargo.lock"), "# generated\n").unwrap();
|
||||||
|
assert!(lockfile_present(&with_lock));
|
||||||
|
let extra = template.rules_extra(&with_lock);
|
||||||
|
assert!(extra.contains("\tcargo build --release --offline --locked\n"));
|
||||||
|
assert!(extra.contains("\tcargo test --release --offline --locked\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Package names may carry `+`/`.` (legal dpkg, rejected by cargo): the
|
||||||
|
/// skeleton crate name is sanitized, and the install override picks the
|
||||||
|
/// crate-named artifact and installs it under the command name.
|
||||||
|
#[test]
|
||||||
|
fn skeleton_sanitizes_the_crate_name() {
|
||||||
|
let o = NewOptions {
|
||||||
|
name: "my.tool+".into(),
|
||||||
|
command: "mytool".into(),
|
||||||
|
..opts(SourceDir::Skeleton)
|
||||||
|
};
|
||||||
|
let template = super::super::get(TemplateId::Rust).unwrap();
|
||||||
|
|
||||||
|
let skeleton = template.skeleton(&o);
|
||||||
|
let cargo_toml = skeleton
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "Cargo.toml")
|
||||||
|
.expect("Cargo.toml skeleton");
|
||||||
|
assert!(cargo_toml.contents.contains("name = \"my_tool_\""));
|
||||||
|
|
||||||
|
let extra = template.rules_extra(&o);
|
||||||
|
assert!(
|
||||||
|
extra.contains(
|
||||||
|
"\tinstall -Dm755 target/release/my_tool_ debian/my.tool+/usr/bin/mytool\n"
|
||||||
|
),
|
||||||
|
"{extra}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_reads_cargo_toml_lines() {
|
||||||
|
let template = super::super::get(TemplateId::Rust).unwrap();
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("Cargo.toml"),
|
||||||
|
"# comment\n\
|
||||||
|
[package]\n\
|
||||||
|
name = \"mytool\"\n\
|
||||||
|
version = \"2.3.4\"\n\
|
||||||
|
description = \"A cargo tool\"\n\
|
||||||
|
homepage = \"https://example.com/mytool\"\n\
|
||||||
|
license = \"MIT OR Apache-2.0\"\n\
|
||||||
|
\n\
|
||||||
|
[dependencies]\n\
|
||||||
|
serde = \"1\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
||||||
|
assert_eq!(probe.version.as_deref(), Some("2.3.4"));
|
||||||
|
assert_eq!(probe.description.as_deref(), Some("A cargo tool"));
|
||||||
|
assert_eq!(
|
||||||
|
probe.homepage.as_deref(),
|
||||||
|
Some("https://example.com/mytool")
|
||||||
|
);
|
||||||
|
assert_eq!(probe.license.as_deref(), Some("MIT OR Apache-2.0"));
|
||||||
|
|
||||||
|
// Only section headers and comments: nothing to report.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("Cargo.toml"), "[dependencies]\n").unwrap();
|
||||||
|
assert!(template.probe(dir.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With host cargo available, `cargo metadata` wins and yields the bin
|
||||||
|
/// target as the command. (Without cargo on PATH the line-parse fallback
|
||||||
|
/// above is exercised.)
|
||||||
|
#[test]
|
||||||
|
fn probe_prefers_cargo_metadata() {
|
||||||
|
if find_on_path("cargo").is_none() {
|
||||||
|
// No cargo on this host: metadata probing is silent and the
|
||||||
|
// fallback applies (already covered by the line-parse test).
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("Cargo.toml"),
|
||||||
|
"[package]\nname = \"metaprobe\"\nversion = \"0.9.0\"\nedition = \"2021\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("src")).unwrap();
|
||||||
|
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||||
|
|
||||||
|
let template = super::super::get(TemplateId::Rust).unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("metaprobe"));
|
||||||
|
assert_eq!(probe.version.as_deref(), Some("0.9.0"));
|
||||||
|
assert_eq!(probe.command.as_deref(), Some("metaprobe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The vendoring hook over a zero-dependency skeleton: offline config
|
||||||
|
/// written, no failure (needs host cargo; without it the warning path
|
||||||
|
/// keeps the tree intact).
|
||||||
|
#[test]
|
||||||
|
fn post_write_vendors_skeleton() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let o = opts(SourceDir::Skeleton);
|
||||||
|
let template = super::super::get(TemplateId::Rust).unwrap();
|
||||||
|
for file in template.skeleton(&o) {
|
||||||
|
let path = dir.path().join(&file.path);
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(path, file.contents).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
template.post_write(&o, dir.path()).unwrap();
|
||||||
|
|
||||||
|
if find_on_path("cargo").is_some() {
|
||||||
|
let config = std::fs::read_to_string(dir.path().join(".cargo/config.toml")).unwrap();
|
||||||
|
assert!(config.contains("[source.crates-io]"), "{config}");
|
||||||
|
assert!(config.contains("replace-with = \"vendored-sources\""));
|
||||||
|
assert!(config.contains("[net]\noffline = true"), "{config}");
|
||||||
|
}
|
||||||
|
// An existing .cargo/config.toml is never overwritten.
|
||||||
|
let existing = dir.path().join(".cargo/config.toml");
|
||||||
|
if existing.exists() {
|
||||||
|
template.post_write(&o, dir.path()).unwrap();
|
||||||
|
let config = std::fs::read_to_string(&existing).unwrap();
|
||||||
|
assert!(config.contains("[source.crates-io]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//! The `shell` template: a single interpreted script installed to
|
||||||
|
//! `/usr/bin` with plain `dh $@` plumbing.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::{OutputFile, ProbeResult, Template};
|
||||||
|
use crate::new::options::{self, 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<OutputFile> {
|
||||||
|
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/<command>`
|
||||||
|
/// (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<OutputFile> {
|
||||||
|
if !matches!(opts.source_dir, SourceDir::Skeleton) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
vec![OutputFile::new(
|
||||||
|
"debian/install",
|
||||||
|
format!("{}.sh usr/bin/{}\n", opts.command, opts.command),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file name of the single top-level script (sanitized) pre-fills the
|
||||||
|
/// package name and command questions.
|
||||||
|
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
||||||
|
let path = crate::new::detect::single_script(dir)?;
|
||||||
|
let stem = path.file_stem()?.to_str()?;
|
||||||
|
let name = options::sanitize_name(stem)?;
|
||||||
|
Some(ProbeResult {
|
||||||
|
command: Some(name.clone()),
|
||||||
|
name: Some(name),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::new::options::{License, 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: "ubuntu".into(),
|
||||||
|
series: "resolute".into(),
|
||||||
|
release: false,
|
||||||
|
depends: Vec::new(),
|
||||||
|
native: false,
|
||||||
|
git: true,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_template_shape() {
|
||||||
|
let o = opts();
|
||||||
|
let template = super::super::get(TemplateId::Shell).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(template.architecture(&o), "all");
|
||||||
|
assert!(template.build_depends(&o).is_empty());
|
||||||
|
assert!(template.rules_extra(&o).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");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_probe_reads_script_file_name() {
|
||||||
|
let template = super::super::get(TemplateId::Shell).unwrap();
|
||||||
|
|
||||||
|
// The .sh extension is stripped, the stem sanitized into a package
|
||||||
|
// name and command.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("My Tool.sh"), "#!/bin/sh\n").unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("my-tool"));
|
||||||
|
assert_eq!(probe.command.as_deref(), Some("my-tool"));
|
||||||
|
|
||||||
|
// A shebang file without extension works too.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("runtool"), "#!/usr/bin/env bash\n").unwrap();
|
||||||
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
|
assert_eq!(probe.name.as_deref(), Some("runtool"));
|
||||||
|
|
||||||
|
// Zero or several scripts: silent None.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
assert!(template.probe(dir.path()).is_none());
|
||||||
|
std::fs::write(dir.path().join("a.sh"), "#!/bin/sh\n").unwrap();
|
||||||
|
std::fs::write(dir.path().join("b.sh"), "#!/bin/sh\n").unwrap();
|
||||||
|
assert!(template.probe(dir.path()).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
//! 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<dyn std::error::Error>> {
|
||||||
|
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,
|
||||||
|
autopkgtest: false,
|
||||||
|
pkg_config: false,
|
||||||
|
watch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,11 @@
|
|||||||
pub mod deb;
|
pub mod deb;
|
||||||
/// Line classifiers rewriting raw subprocess output for the live views
|
/// Line classifiers rewriting raw subprocess output for the live views
|
||||||
pub mod logfmt;
|
pub mod logfmt;
|
||||||
|
/// Interactive raw-mode prompts: free-text input, option selection and
|
||||||
|
/// yes/no confirmation
|
||||||
|
pub mod prompt;
|
||||||
|
|
||||||
use crossterm::{
|
|
||||||
cursor, event, execute,
|
|
||||||
style::{self, Color, Print, SetForegroundColor},
|
|
||||||
terminal,
|
|
||||||
};
|
|
||||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
use std::io::{self, Write};
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -75,7 +72,9 @@ pub fn create_progress_bar(
|
|||||||
(pb, callback)
|
(pb, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SELECT_PROMPT: &str = "> Select series: ";
|
/// Label of the interactive series selector, rendered by [`prompt::select`]
|
||||||
|
/// as `> <label><choice>`
|
||||||
|
const SELECT_SERIES_LABEL: &str = "Select series: ";
|
||||||
|
|
||||||
/// Interactive one-line series selector with arrow key navigation and direct typing.
|
/// Interactive one-line series selector with arrow key navigation and direct typing.
|
||||||
///
|
///
|
||||||
@@ -86,165 +85,27 @@ const SELECT_PROMPT: &str = "> Select series: ";
|
|||||||
/// - Press Enter to confirm, Escape to use the default
|
/// - Press Enter to confirm, Escape to use the default
|
||||||
///
|
///
|
||||||
/// Returns the selected series name, or an error if cancelled (Ctrl+C).
|
/// Returns the selected series name, or an error if cancelled (Ctrl+C).
|
||||||
|
/// Without a terminal (piped input) or with no options, falls back to the
|
||||||
|
/// default.
|
||||||
pub fn select_series(
|
pub fn select_series(
|
||||||
options: &[String],
|
options: &[String],
|
||||||
default: &str,
|
default: &str,
|
||||||
) -> Result<String, Box<dyn std::error::Error>> {
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
if options.is_empty() {
|
// Nothing to choose, or no terminal to ask: announce the default and take it
|
||||||
println!("{}{} (default)", SELECT_PROMPT, default);
|
let fallback = || {
|
||||||
return Ok(default.to_string());
|
println!("> {SELECT_SERIES_LABEL}{default} (default)");
|
||||||
}
|
Ok(default.to_string())
|
||||||
|
|
||||||
// Try to enter raw mode — if not possible (e.g. piped input), fall back to default
|
|
||||||
if terminal::enable_raw_mode().is_err() {
|
|
||||||
println!("{}{} (default)", SELECT_PROMPT, default);
|
|
||||||
return Ok(default.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = select_series_inner(options, default);
|
|
||||||
|
|
||||||
// Always restore the terminal
|
|
||||||
let _ = terminal::disable_raw_mode();
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select_series_inner(
|
|
||||||
options: &[String],
|
|
||||||
default: &str,
|
|
||||||
) -> Result<String, Box<dyn std::error::Error>> {
|
|
||||||
let default_idx = options.iter().position(|o| o == default).unwrap_or(0);
|
|
||||||
let mut selected_idx = default_idx;
|
|
||||||
let mut input = default.to_string();
|
|
||||||
// Whether we are in "navigation mode" (last action was arrow/tab selecting an option)
|
|
||||||
// vs "typing mode" (last action was typing a character)
|
|
||||||
let mut navigating = true;
|
|
||||||
|
|
||||||
let prompt_len = SELECT_PROMPT.len();
|
|
||||||
|
|
||||||
let mut stdout = io::stdout();
|
|
||||||
|
|
||||||
// Draw the prompt line, clearing any previous content
|
|
||||||
let draw = |stdout: &mut io::Stdout, input: &str| -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
execute!(
|
|
||||||
stdout,
|
|
||||||
cursor::MoveToColumn(0),
|
|
||||||
Print(SELECT_PROMPT),
|
|
||||||
SetForegroundColor(Color::Cyan),
|
|
||||||
Print(input),
|
|
||||||
style::ResetColor,
|
|
||||||
terminal::Clear(terminal::ClearType::UntilNewLine),
|
|
||||||
)?;
|
|
||||||
stdout.flush()?;
|
|
||||||
Ok(())
|
|
||||||
};
|
};
|
||||||
|
|
||||||
draw(&mut stdout, &input)?;
|
if options.is_empty() {
|
||||||
|
return fallback();
|
||||||
loop {
|
|
||||||
if event::poll(Duration::from_millis(200))?
|
|
||||||
&& let event::Event::Key(event::KeyEvent {
|
|
||||||
code, modifiers, ..
|
|
||||||
}) = event::read()?
|
|
||||||
{
|
|
||||||
match code {
|
|
||||||
event::KeyCode::Up => {
|
|
||||||
navigating = true;
|
|
||||||
if selected_idx > 0 {
|
|
||||||
selected_idx -= 1;
|
|
||||||
} else {
|
|
||||||
selected_idx = options.len() - 1;
|
|
||||||
}
|
|
||||||
input = options[selected_idx].clone();
|
|
||||||
draw(&mut stdout, &input)?;
|
|
||||||
}
|
|
||||||
event::KeyCode::Down => {
|
|
||||||
navigating = true;
|
|
||||||
if selected_idx < options.len() - 1 {
|
|
||||||
selected_idx += 1;
|
|
||||||
} else {
|
|
||||||
selected_idx = 0;
|
|
||||||
}
|
|
||||||
input = options[selected_idx].clone();
|
|
||||||
draw(&mut stdout, &input)?;
|
|
||||||
}
|
|
||||||
event::KeyCode::Enter => {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
event::KeyCode::Esc => {
|
|
||||||
input = default.to_string();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
event::KeyCode::Char(c) => {
|
|
||||||
if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' {
|
|
||||||
// Move to a new line before returning so the terminal isn't messed up
|
|
||||||
execute!(stdout, Print("\r\n"))?;
|
|
||||||
return Err("Cancelled".into());
|
|
||||||
}
|
|
||||||
navigating = false;
|
|
||||||
input.push(c);
|
|
||||||
// Auto-select if the input exactly matches an option
|
|
||||||
if let Some(idx) = options.iter().position(|o| o == &input) {
|
|
||||||
selected_idx = idx;
|
|
||||||
navigating = true;
|
|
||||||
}
|
|
||||||
draw(&mut stdout, &input)?;
|
|
||||||
}
|
|
||||||
event::KeyCode::Backspace => {
|
|
||||||
if !input.is_empty() {
|
|
||||||
input.pop();
|
|
||||||
navigating = false;
|
|
||||||
if let Some(idx) = options.iter().position(|o| o == &input) {
|
|
||||||
selected_idx = idx;
|
|
||||||
navigating = true;
|
|
||||||
}
|
|
||||||
// Need to redraw — move cursor to end of prompt + input
|
|
||||||
execute!(
|
|
||||||
stdout,
|
|
||||||
cursor::MoveToColumn(prompt_len as u16),
|
|
||||||
Print(&input),
|
|
||||||
terminal::Clear(terminal::ClearType::UntilNewLine),
|
|
||||||
)?;
|
|
||||||
stdout.flush()?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
event::KeyCode::Tab => {
|
|
||||||
// Cycle through options that start with the current input
|
|
||||||
let matches: Vec<usize> = options
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, o)| o.starts_with(&input))
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
.collect();
|
|
||||||
if !matches.is_empty() {
|
|
||||||
let next = if navigating {
|
|
||||||
matches
|
|
||||||
.iter()
|
|
||||||
.find(|&&i| i > selected_idx)
|
|
||||||
.or_else(|| matches.first())
|
|
||||||
} else {
|
|
||||||
matches.first()
|
|
||||||
};
|
|
||||||
if let Some(&idx) = next {
|
|
||||||
selected_idx = idx;
|
|
||||||
input = options[idx].clone();
|
|
||||||
navigating = true;
|
|
||||||
}
|
|
||||||
draw(&mut stdout, &input)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to a new line so subsequent output doesn't overwrite the prompt
|
match prompt::select(SELECT_SERIES_LABEL, options, default) {
|
||||||
execute!(
|
Ok(series) => Ok(series),
|
||||||
stdout,
|
// prompt::select refuses to answer without a TTY; keep the
|
||||||
terminal::Clear(terminal::ClearType::UntilNewLine),
|
// historical fallback to the default
|
||||||
Print("\r\n"),
|
Err(err) if err.is::<prompt::PromptError>() => fallback(),
|
||||||
)?;
|
Err(err) => Err(err),
|
||||||
stdout.flush()?;
|
}
|
||||||
|
|
||||||
Ok(input)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,747 @@
|
|||||||
|
//! Interactive terminal prompts sharing one raw-mode event loop: free-text
|
||||||
|
//! input with an optional validator, one-line option selection and yes/no
|
||||||
|
//! confirmation.
|
||||||
|
//!
|
||||||
|
//! Every public prompt enables raw mode itself and always restores it. When
|
||||||
|
//! no interactive terminal is attached, [`select`] fails with
|
||||||
|
//! [`PromptError::NotATty`] so callers decide on a fallback, while [`text`]
|
||||||
|
//! and [`confirm`] answer with their default.
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
cursor, event, execute,
|
||||||
|
style::{self, Color, Print, SetForegroundColor},
|
||||||
|
terminal,
|
||||||
|
};
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Why a prompt could not run interactively
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PromptError {
|
||||||
|
/// Raw mode could not be enabled: stdin is not an interactive terminal
|
||||||
|
NotATty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PromptError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
PromptError::NotATty => write!(f, "no interactive terminal available"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PromptError {}
|
||||||
|
|
||||||
|
/// Answer validator of [`text`]: accepts the answer, or explains why it is
|
||||||
|
/// rejected
|
||||||
|
pub type Validator = dyn Fn(&str) -> Result<(), String>;
|
||||||
|
|
||||||
|
/// What a prompt's event loop asks its drawing helper to render; keeping all
|
||||||
|
/// terminal access behind this callback makes the loops pure logic that unit
|
||||||
|
/// tests can drive with synthetic key events.
|
||||||
|
enum Render<'a> {
|
||||||
|
/// Redraw the prompt line showing this input
|
||||||
|
Line(&'a str),
|
||||||
|
/// Print a rejection message on its own line below the prompt
|
||||||
|
Rejected(&'a str),
|
||||||
|
/// Keep the line, clear any trailing characters and move to the next
|
||||||
|
/// line: the prompt was answered
|
||||||
|
Done,
|
||||||
|
/// Keep the line and move to the next line: the prompt was cancelled
|
||||||
|
/// with Ctrl+C
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interactive one-line selector: ↑/↓ cycle through `options`, typing edits
|
||||||
|
/// the answer directly, Tab cycles through the options matching the current
|
||||||
|
/// input prefix, Enter confirms and Esc restores `default`. Ctrl+C cancels.
|
||||||
|
///
|
||||||
|
/// The line is rendered as `> <label><choice>`. With no options there is
|
||||||
|
/// nothing to choose and `default` is returned unchanged; without an
|
||||||
|
/// interactive terminal the call fails with [`PromptError::NotATty`] so the
|
||||||
|
/// caller decides on a fallback (see [`super::select_series`]).
|
||||||
|
pub fn select(
|
||||||
|
label: &str,
|
||||||
|
options: &[String],
|
||||||
|
default: &str,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
if options.is_empty() {
|
||||||
|
return Ok(default.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
with_raw_mode(|| {
|
||||||
|
select_inner(
|
||||||
|
options,
|
||||||
|
default,
|
||||||
|
terminal_events,
|
||||||
|
renderer(format!("> {label}")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-line free-text input: printable characters append, Backspace
|
||||||
|
/// deletes, Enter confirms, Esc restores `default` and Ctrl+C cancels.
|
||||||
|
///
|
||||||
|
/// The line is rendered as `> <label> [<default>]: `. When `validator`
|
||||||
|
/// rejects an answer, its error is printed below the prompt and the question
|
||||||
|
/// is asked again; Esc and the non-TTY fallback skip validation.
|
||||||
|
pub fn text(
|
||||||
|
label: &str,
|
||||||
|
default: &str,
|
||||||
|
validator: Option<&Validator>,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let prefix = text_prefix(label, default);
|
||||||
|
|
||||||
|
match with_raw_mode(|| {
|
||||||
|
text_inner(
|
||||||
|
default,
|
||||||
|
validator,
|
||||||
|
terminal_events,
|
||||||
|
renderer(prefix.clone()),
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
// Not a TTY: the answer is the default, unvalidated
|
||||||
|
Err(err) if err.is::<PromptError>() => {
|
||||||
|
println!("{prefix}{default}");
|
||||||
|
Ok(default.to_string())
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yes/no confirmation: answers y/yes/n/no (case-insensitive), Enter with an
|
||||||
|
/// empty answer or Esc takes `default`, Ctrl+C cancels.
|
||||||
|
///
|
||||||
|
/// The line is rendered as `> <label> [Y/n] `, the capital letter marking the
|
||||||
|
/// default answer. Without an interactive terminal the default is taken.
|
||||||
|
pub fn confirm(label: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
|
let prefix = confirm_prefix(label, default);
|
||||||
|
|
||||||
|
match with_raw_mode(|| confirm_inner(default, terminal_events, renderer(prefix.clone()))) {
|
||||||
|
// Not a TTY: the answer is the default
|
||||||
|
Err(err) if err.is::<PromptError>() => {
|
||||||
|
println!("{prefix}{}", if default { 'y' } else { 'n' });
|
||||||
|
Ok(default)
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `prompt` with the terminal in raw mode, always restoring it
|
||||||
|
/// afterwards. Fails with [`PromptError::NotATty`] when raw mode cannot be
|
||||||
|
/// enabled.
|
||||||
|
fn with_raw_mode<T>(
|
||||||
|
prompt: impl FnOnce() -> Result<T, Box<dyn std::error::Error>>,
|
||||||
|
) -> Result<T, Box<dyn std::error::Error>> {
|
||||||
|
// Try to enter raw mode — if not possible (e.g. piped input), the caller
|
||||||
|
// falls back to a non-interactive answer
|
||||||
|
if terminal::enable_raw_mode().is_err() {
|
||||||
|
return Err(Box::new(PromptError::NotATty));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = prompt();
|
||||||
|
|
||||||
|
// Always restore the terminal
|
||||||
|
let _ = terminal::disable_raw_mode();
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real key source: wait up to 200 ms for the next terminal event, `None`
|
||||||
|
/// when nothing arrived (the event loop keeps polling)
|
||||||
|
fn terminal_events() -> io::Result<Option<event::Event>> {
|
||||||
|
if event::poll(Duration::from_millis(200))? {
|
||||||
|
Ok(Some(event::read()?))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drawing helper for a prompt whose line is `prefix` followed by the current
|
||||||
|
/// input, rendered in cyan
|
||||||
|
fn renderer(prefix: String) -> impl FnMut(Render<'_>) -> io::Result<()> {
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
|
||||||
|
move |action| {
|
||||||
|
match action {
|
||||||
|
Render::Line(input) => {
|
||||||
|
execute!(
|
||||||
|
stdout,
|
||||||
|
cursor::MoveToColumn(0),
|
||||||
|
Print(&prefix),
|
||||||
|
SetForegroundColor(Color::Cyan),
|
||||||
|
Print(input),
|
||||||
|
style::ResetColor,
|
||||||
|
terminal::Clear(terminal::ClearType::UntilNewLine),
|
||||||
|
)?;
|
||||||
|
stdout.flush()?;
|
||||||
|
}
|
||||||
|
Render::Rejected(message) => {
|
||||||
|
execute!(
|
||||||
|
stdout,
|
||||||
|
Print("\r\n"),
|
||||||
|
SetForegroundColor(Color::Red),
|
||||||
|
Print(message),
|
||||||
|
style::ResetColor,
|
||||||
|
Print("\r\n"),
|
||||||
|
)?;
|
||||||
|
stdout.flush()?;
|
||||||
|
}
|
||||||
|
Render::Done => {
|
||||||
|
execute!(
|
||||||
|
stdout,
|
||||||
|
terminal::Clear(terminal::ClearType::UntilNewLine),
|
||||||
|
Print("\r\n"),
|
||||||
|
)?;
|
||||||
|
stdout.flush()?;
|
||||||
|
}
|
||||||
|
Render::Cancelled => {
|
||||||
|
execute!(stdout, Print("\r\n"))?;
|
||||||
|
stdout.flush()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt line of [`text`]: `> label [default]: `, without the brackets when
|
||||||
|
/// there is no default to show
|
||||||
|
fn text_prefix(label: &str, default: &str) -> String {
|
||||||
|
if default.is_empty() {
|
||||||
|
format!("> {label}: ")
|
||||||
|
} else {
|
||||||
|
format!("> {label} [{default}]: ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt line of [`confirm`]: `> label [Y/n] `, the capital letter marking
|
||||||
|
/// the default answer
|
||||||
|
fn confirm_prefix(label: &str, default: bool) -> String {
|
||||||
|
let hint = if default { "Y/n" } else { "y/N" };
|
||||||
|
format!("> {label} [{hint}] ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event loop of [`select`]: pure logic over `next_event`, rendering through
|
||||||
|
/// `render` (see [`Render`])
|
||||||
|
fn select_inner(
|
||||||
|
options: &[String],
|
||||||
|
default: &str,
|
||||||
|
mut next_event: impl FnMut() -> io::Result<Option<event::Event>>,
|
||||||
|
mut render: impl FnMut(Render<'_>) -> io::Result<()>,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let default_idx = options.iter().position(|o| o == default).unwrap_or(0);
|
||||||
|
let mut selected_idx = default_idx;
|
||||||
|
let mut input = default.to_string();
|
||||||
|
// Whether we are in "navigation mode" (last action was arrow/tab selecting an option)
|
||||||
|
// vs "typing mode" (last action was typing a character)
|
||||||
|
let mut navigating = true;
|
||||||
|
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(event::Event::Key(event::KeyEvent {
|
||||||
|
code, modifiers, ..
|
||||||
|
})) = next_event()?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match code {
|
||||||
|
event::KeyCode::Up => {
|
||||||
|
navigating = true;
|
||||||
|
if selected_idx > 0 {
|
||||||
|
selected_idx -= 1;
|
||||||
|
} else {
|
||||||
|
selected_idx = options.len() - 1;
|
||||||
|
}
|
||||||
|
input = options[selected_idx].clone();
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
}
|
||||||
|
event::KeyCode::Down => {
|
||||||
|
navigating = true;
|
||||||
|
if selected_idx < options.len() - 1 {
|
||||||
|
selected_idx += 1;
|
||||||
|
} else {
|
||||||
|
selected_idx = 0;
|
||||||
|
}
|
||||||
|
input = options[selected_idx].clone();
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
}
|
||||||
|
event::KeyCode::Enter => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
event::KeyCode::Esc => {
|
||||||
|
input = default.to_string();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
event::KeyCode::Char(c) => {
|
||||||
|
if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' {
|
||||||
|
// Move to a new line before returning so the terminal isn't messed up
|
||||||
|
render(Render::Cancelled)?;
|
||||||
|
return Err("Cancelled".into());
|
||||||
|
}
|
||||||
|
navigating = false;
|
||||||
|
input.push(c);
|
||||||
|
// Auto-select if the input exactly matches an option
|
||||||
|
if let Some(idx) = options.iter().position(|o| o == &input) {
|
||||||
|
selected_idx = idx;
|
||||||
|
navigating = true;
|
||||||
|
}
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
}
|
||||||
|
event::KeyCode::Backspace => {
|
||||||
|
if !input.is_empty() {
|
||||||
|
input.pop();
|
||||||
|
navigating = false;
|
||||||
|
if let Some(idx) = options.iter().position(|o| o == &input) {
|
||||||
|
selected_idx = idx;
|
||||||
|
navigating = true;
|
||||||
|
}
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event::KeyCode::Tab => {
|
||||||
|
// Cycle through options that start with the current input
|
||||||
|
let matches: Vec<usize> = options
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, o)| o.starts_with(&input))
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
if !matches.is_empty() {
|
||||||
|
let next = if navigating {
|
||||||
|
matches
|
||||||
|
.iter()
|
||||||
|
.find(|&&i| i > selected_idx)
|
||||||
|
.or_else(|| matches.first())
|
||||||
|
} else {
|
||||||
|
matches.first()
|
||||||
|
};
|
||||||
|
if let Some(&idx) = next {
|
||||||
|
selected_idx = idx;
|
||||||
|
input = options[idx].clone();
|
||||||
|
navigating = true;
|
||||||
|
}
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to a new line so subsequent output doesn't overwrite the prompt
|
||||||
|
render(Render::Done)?;
|
||||||
|
|
||||||
|
Ok(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event loop of [`text`]: pure logic over `next_event`, rendering through
|
||||||
|
/// `render` (see [`Render`])
|
||||||
|
fn text_inner(
|
||||||
|
default: &str,
|
||||||
|
validator: Option<&Validator>,
|
||||||
|
mut next_event: impl FnMut() -> io::Result<Option<event::Event>>,
|
||||||
|
mut render: impl FnMut(Render<'_>) -> io::Result<()>,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let mut input = String::new();
|
||||||
|
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(event::Event::Key(event::KeyEvent {
|
||||||
|
code, modifiers, ..
|
||||||
|
})) = next_event()?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match code {
|
||||||
|
event::KeyCode::Enter => {
|
||||||
|
if let Some(validator) = validator
|
||||||
|
&& let Err(message) = validator(&input)
|
||||||
|
{
|
||||||
|
render(Render::Rejected(&message))?;
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
event::KeyCode::Esc => {
|
||||||
|
input = default.to_string();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
event::KeyCode::Char(c) => {
|
||||||
|
if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' {
|
||||||
|
render(Render::Cancelled)?;
|
||||||
|
return Err("Cancelled".into());
|
||||||
|
}
|
||||||
|
input.push(c);
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
}
|
||||||
|
event::KeyCode::Backspace => {
|
||||||
|
if !input.is_empty() {
|
||||||
|
input.pop();
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(Render::Done)?;
|
||||||
|
|
||||||
|
Ok(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event loop of [`confirm`]: pure logic over `next_event`, rendering through
|
||||||
|
/// `render` (see [`Render`])
|
||||||
|
fn confirm_inner(
|
||||||
|
default: bool,
|
||||||
|
mut next_event: impl FnMut() -> io::Result<Option<event::Event>>,
|
||||||
|
mut render: impl FnMut(Render<'_>) -> io::Result<()>,
|
||||||
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
|
let mut input = String::new();
|
||||||
|
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
|
||||||
|
let answer = loop {
|
||||||
|
let Some(event::Event::Key(event::KeyEvent {
|
||||||
|
code, modifiers, ..
|
||||||
|
})) = next_event()?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match code {
|
||||||
|
event::KeyCode::Enter => match input.to_lowercase().as_str() {
|
||||||
|
"" => break default,
|
||||||
|
"y" | "yes" => break true,
|
||||||
|
"n" | "no" => break false,
|
||||||
|
_ => {
|
||||||
|
render(Render::Rejected("Please answer y/yes or n/no"))?;
|
||||||
|
input.clear();
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
event::KeyCode::Esc => break default,
|
||||||
|
event::KeyCode::Char(c) => {
|
||||||
|
if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' {
|
||||||
|
render(Render::Cancelled)?;
|
||||||
|
return Err("Cancelled".into());
|
||||||
|
}
|
||||||
|
input.push(c);
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
}
|
||||||
|
event::KeyCode::Backspace => {
|
||||||
|
if !input.is_empty() {
|
||||||
|
input.pop();
|
||||||
|
render(Render::Line(&input))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render(Render::Done)?;
|
||||||
|
|
||||||
|
Ok(answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
/// Key press with no modifiers
|
||||||
|
fn key(code: KeyCode) -> event::Event {
|
||||||
|
event::Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ctrl+C key press
|
||||||
|
fn ctrl_c() -> event::Event {
|
||||||
|
event::Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event source replaying `events`, erroring once exhausted so a missing
|
||||||
|
/// Enter/Esc/Ctrl+C fails the test instead of hanging it
|
||||||
|
fn replay(events: Vec<event::Event>) -> impl FnMut() -> io::Result<Option<event::Event>> {
|
||||||
|
let mut events = events.into_iter();
|
||||||
|
move || match events.next() {
|
||||||
|
Some(event) => Ok(Some(event)),
|
||||||
|
None => Err(io::Error::new(
|
||||||
|
io::ErrorKind::UnexpectedEof,
|
||||||
|
"no more events",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drawing helper ignoring all rendering
|
||||||
|
fn nop_render(_: Render<'_>) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_down_cycles_with_wraparound() {
|
||||||
|
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
|
||||||
|
// a → b → c → wraps back to a
|
||||||
|
let mut events = vec![key(KeyCode::Down); 3];
|
||||||
|
events.push(key(KeyCode::Enter));
|
||||||
|
|
||||||
|
let selected = select_inner(&options, "a", replay(events), nop_render).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(selected, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_up_wraps_to_last_option() {
|
||||||
|
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
|
||||||
|
let events = vec![key(KeyCode::Up), key(KeyCode::Enter)];
|
||||||
|
|
||||||
|
let selected = select_inner(&options, "a", replay(events), nop_render).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(selected, "c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_exact_match_autoselects() {
|
||||||
|
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
|
||||||
|
// Clear the default ("a"), type "b": it exactly matches an option and
|
||||||
|
// auto-selects it, so the following Down starts from "b"
|
||||||
|
let events = vec![
|
||||||
|
key(KeyCode::Backspace),
|
||||||
|
key(KeyCode::Char('b')),
|
||||||
|
key(KeyCode::Down),
|
||||||
|
key(KeyCode::Enter),
|
||||||
|
];
|
||||||
|
|
||||||
|
let selected = select_inner(&options, "a", replay(events), nop_render).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(selected, "c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_tab_walks_prefix_chain() {
|
||||||
|
let options = vec!["n".to_string(), "no".to_string(), "noble".to_string()];
|
||||||
|
// Tab cycles through the options matching the current input:
|
||||||
|
// "n" → "no" → "noble"
|
||||||
|
let events = vec![key(KeyCode::Tab), key(KeyCode::Tab), key(KeyCode::Enter)];
|
||||||
|
|
||||||
|
let selected = select_inner(&options, "n", replay(events), nop_render).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(selected, "noble");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_tab_wraps_around_matches() {
|
||||||
|
// "a" (the default) is the last option matching the prefix "a", so
|
||||||
|
// Tab wraps back to the first one
|
||||||
|
let options = vec!["ab".to_string(), "a".to_string()];
|
||||||
|
let events = vec![key(KeyCode::Tab), key(KeyCode::Enter)];
|
||||||
|
|
||||||
|
let selected = select_inner(&options, "a", replay(events), nop_render).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(selected, "ab");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_tab_picks_first_match_after_typing() {
|
||||||
|
let options = vec!["n".to_string(), "no".to_string(), "noble".to_string()];
|
||||||
|
// Clear the default ("noble"), type "n" (auto-selects "n"): the next
|
||||||
|
// Tab takes the following match of the prefix
|
||||||
|
let mut events = vec![key(KeyCode::Backspace); 5];
|
||||||
|
events.extend([
|
||||||
|
key(KeyCode::Char('n')),
|
||||||
|
key(KeyCode::Tab),
|
||||||
|
key(KeyCode::Enter),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let selected = select_inner(&options, "noble", replay(events), nop_render).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(selected, "no");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_esc_returns_default() {
|
||||||
|
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
|
||||||
|
let events = vec![
|
||||||
|
key(KeyCode::Char('x')),
|
||||||
|
key(KeyCode::Char('y')),
|
||||||
|
key(KeyCode::Esc),
|
||||||
|
];
|
||||||
|
|
||||||
|
let selected = select_inner(&options, "b", replay(events), nop_render).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(selected, "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_ignores_unknown_keys() {
|
||||||
|
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
|
||||||
|
let events = vec![
|
||||||
|
key(KeyCode::Left),
|
||||||
|
key(KeyCode::Right),
|
||||||
|
key(KeyCode::Home),
|
||||||
|
key(KeyCode::End),
|
||||||
|
key(KeyCode::Delete),
|
||||||
|
key(KeyCode::PageUp),
|
||||||
|
key(KeyCode::F(1)),
|
||||||
|
event::Event::Resize(80, 24),
|
||||||
|
key(KeyCode::Enter),
|
||||||
|
];
|
||||||
|
|
||||||
|
let selected = select_inner(&options, "jammy", replay(events), nop_render).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(selected, "jammy");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_ctrl_c_cancels() {
|
||||||
|
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
|
||||||
|
let events = vec![key(KeyCode::Char('x')), ctrl_c()];
|
||||||
|
|
||||||
|
let err = select_inner(&options, "a", replay(events), nop_render).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err.to_string(), "Cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_appends_characters() {
|
||||||
|
let events = vec![
|
||||||
|
key(KeyCode::Char('h')),
|
||||||
|
key(KeyCode::Char('i')),
|
||||||
|
key(KeyCode::Enter),
|
||||||
|
];
|
||||||
|
|
||||||
|
let answer = text_inner("0.1.0", None, replay(events), nop_render).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(answer, "hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_backspace_deletes() {
|
||||||
|
// Backspace on an empty line is a no-op, then "ab" → Backspace → "a"
|
||||||
|
let events = vec![
|
||||||
|
key(KeyCode::Backspace),
|
||||||
|
key(KeyCode::Char('a')),
|
||||||
|
key(KeyCode::Char('b')),
|
||||||
|
key(KeyCode::Backspace),
|
||||||
|
key(KeyCode::Enter),
|
||||||
|
];
|
||||||
|
|
||||||
|
let answer = text_inner("0.1.0", None, replay(events), nop_render).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(answer, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_esc_returns_default() {
|
||||||
|
let events = vec![key(KeyCode::Char('x')), key(KeyCode::Esc)];
|
||||||
|
|
||||||
|
let answer = text_inner("0.1.0", None, replay(events), nop_render).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(answer, "0.1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validator only accepting the literal answer "ok"
|
||||||
|
fn require_ok(answer: &str) -> Result<(), String> {
|
||||||
|
if answer == "ok" {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("not ok: {answer}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_validator_rejects_then_accepts() {
|
||||||
|
// "no" is rejected (the loop keeps going), then edited into "ok"
|
||||||
|
let events = vec![
|
||||||
|
key(KeyCode::Char('n')),
|
||||||
|
key(KeyCode::Char('o')),
|
||||||
|
key(KeyCode::Enter),
|
||||||
|
key(KeyCode::Backspace),
|
||||||
|
key(KeyCode::Backspace),
|
||||||
|
key(KeyCode::Char('o')),
|
||||||
|
key(KeyCode::Char('k')),
|
||||||
|
key(KeyCode::Enter),
|
||||||
|
];
|
||||||
|
|
||||||
|
let answer = text_inner("0.1.0", Some(&require_ok), replay(events), nop_render).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(answer, "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_ctrl_c_cancels() {
|
||||||
|
let events = vec![key(KeyCode::Char('x')), ctrl_c()];
|
||||||
|
|
||||||
|
let err = text_inner("0.1.0", None, replay(events), nop_render).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err.to_string(), "Cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_y_and_yes_agree() {
|
||||||
|
let yes = vec![key(KeyCode::Char('y')), key(KeyCode::Enter)];
|
||||||
|
let yes_spelled = vec![
|
||||||
|
key(KeyCode::Char('Y')),
|
||||||
|
key(KeyCode::Char('e')),
|
||||||
|
key(KeyCode::Char('S')),
|
||||||
|
key(KeyCode::Enter),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert!(confirm_inner(false, replay(yes), nop_render).unwrap());
|
||||||
|
assert!(confirm_inner(false, replay(yes_spelled), nop_render).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_n_and_no_deny() {
|
||||||
|
let no = vec![key(KeyCode::Char('n')), key(KeyCode::Enter)];
|
||||||
|
let no_spelled = vec![
|
||||||
|
key(KeyCode::Char('N')),
|
||||||
|
key(KeyCode::Char('o')),
|
||||||
|
key(KeyCode::Enter),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert!(!confirm_inner(true, replay(no), nop_render).unwrap());
|
||||||
|
assert!(!confirm_inner(true, replay(no_spelled), nop_render).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_enter_takes_default() {
|
||||||
|
let events = vec![key(KeyCode::Enter)];
|
||||||
|
|
||||||
|
assert!(confirm_inner(true, replay(events.clone()), nop_render).unwrap());
|
||||||
|
assert!(!confirm_inner(false, replay(events), nop_render).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_esc_takes_default() {
|
||||||
|
// A typed answer is discarded by Esc
|
||||||
|
let events = vec![key(KeyCode::Char('n')), key(KeyCode::Esc)];
|
||||||
|
|
||||||
|
assert!(confirm_inner(true, replay(events), nop_render).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_invalid_answer_reprompts() {
|
||||||
|
// "x" is rejected, the loop continues and "y" is accepted
|
||||||
|
let events = vec![
|
||||||
|
key(KeyCode::Char('x')),
|
||||||
|
key(KeyCode::Enter),
|
||||||
|
key(KeyCode::Char('y')),
|
||||||
|
key(KeyCode::Enter),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert!(confirm_inner(false, replay(events), nop_render).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_ctrl_c_cancels() {
|
||||||
|
let events = vec![ctrl_c()];
|
||||||
|
|
||||||
|
let err = confirm_inner(true, replay(events), nop_render).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err.to_string(), "Cancelled");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user