new: scaffold new Debian source packages (non-interactive core)
This commit is contained in:
@@ -0,0 +1,943 @@
|
||||
//! Answers, validators and flag/default resolution for `pkh new`.
|
||||
//!
|
||||
//! [`NewCli`] carries the raw command-line answers (every flag optional),
|
||||
//! [`resolve`] merges them with the built-in defaults and the project
|
||||
//! detection (see [`crate::new::detect`]) into a fully-specified
|
||||
//! [`NewOptions`], erroring with the list of every missing required answer
|
||||
//! when running without an interactive wizard.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::debian::DebianVersion;
|
||||
use crate::debian::deps::{Deps, ParseOpts};
|
||||
use crate::distro_info;
|
||||
use crate::new::detect::{self, Detection};
|
||||
|
||||
/// Build systems / project kinds `pkh new` knows about.
|
||||
///
|
||||
/// The identifiers are stable CLI surface: `--lang` accepts every variant,
|
||||
/// even the ones that have no template implementation yet (those fail at
|
||||
/// scaffold time with a "not implemented yet" error instead of a parse
|
||||
/// error, so scripts written today keep working once they land).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum TemplateId {
|
||||
/// Rust project (`Cargo.toml`)
|
||||
Rust,
|
||||
/// Python project (`pyproject.toml` / `setup.py` / `setup.cfg`)
|
||||
Python,
|
||||
/// C/C++ with Meson (`meson.build`)
|
||||
Meson,
|
||||
/// C/C++ with CMake (`CMakeLists.txt`)
|
||||
Cmake,
|
||||
/// C/C++ with Autotools (`configure.ac`)
|
||||
Autotools,
|
||||
/// Go module (`go.mod`)
|
||||
Go,
|
||||
/// Shell script / single interpreted file
|
||||
Shell,
|
||||
/// Generic Makefile-based project
|
||||
Makefile,
|
||||
/// Metapackage / empty base (no build system)
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl TemplateId {
|
||||
/// Every template id, in the order offered by the wizard language menu.
|
||||
pub fn all() -> [TemplateId; 9] {
|
||||
[
|
||||
TemplateId::Rust,
|
||||
TemplateId::Python,
|
||||
TemplateId::Meson,
|
||||
TemplateId::Cmake,
|
||||
TemplateId::Autotools,
|
||||
TemplateId::Go,
|
||||
TemplateId::Shell,
|
||||
TemplateId::Makefile,
|
||||
TemplateId::Empty,
|
||||
]
|
||||
}
|
||||
|
||||
/// Canonical CLI identifier of this template.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
TemplateId::Rust => "rust",
|
||||
TemplateId::Python => "python",
|
||||
TemplateId::Meson => "meson",
|
||||
TemplateId::Cmake => "cmake",
|
||||
TemplateId::Autotools => "autotools",
|
||||
TemplateId::Go => "go",
|
||||
TemplateId::Shell => "shell",
|
||||
TemplateId::Makefile => "makefile",
|
||||
TemplateId::Empty => "empty",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a CLI identifier, accepting exactly the canonical spellings.
|
||||
pub fn parse(s: &str) -> Result<TemplateId, 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(", ")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a template implementation (and therefore scaffolding) exists
|
||||
/// for this id. The remaining templates land with the follow-up work.
|
||||
pub fn implemented(&self) -> bool {
|
||||
matches!(self, TemplateId::Shell | TemplateId::Empty)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TemplateId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the upstream sources come from, and where the package tree lives.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SourceDir {
|
||||
/// Generate a fresh project skeleton in `./<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,
|
||||
}
|
||||
|
||||
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 answer interactively once the wizard lands):\n - {}",
|
||||
missing.join("\n - ")
|
||||
));
|
||||
}
|
||||
|
||||
// Everything below has a default and is validated as it is resolved.
|
||||
validate_source_name(&name)?;
|
||||
|
||||
let upstream_version = cli.upstream_version.unwrap_or_else(|| "0.1.0".to_string());
|
||||
let revision = cli.revision.unwrap_or(1);
|
||||
validate_upstream_version(&upstream_version, revision)
|
||||
.map_err(|e| format!("Invalid upstream version: {e}"))?;
|
||||
|
||||
let homepage = match &cli.homepage {
|
||||
Some(h) => {
|
||||
validate_homepage(h)?;
|
||||
Some(h.clone())
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let license = License::parse(cli.license.as_deref().unwrap_or("unknown"));
|
||||
|
||||
let command = cli.command.unwrap_or_else(|| name.clone());
|
||||
|
||||
let maintainer = match &cli.maintainer {
|
||||
Some(m) => parse_maintainer(m)?,
|
||||
None => crate::changelog::get_maintainer_info().map_err(|e| {
|
||||
format!(
|
||||
"Could not determine the maintainer ({e}). \
|
||||
Pass --maintainer \"Name <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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set of names no template may produce twice (collision check).
|
||||
pub(crate) fn check_file_collisions(paths: &[String]) -> Result<(), String> {
|
||||
let mut seen: HashSet<&String> = HashSet::new();
|
||||
for path in paths {
|
||||
if !seen.insert(path) {
|
||||
return Err(format!(
|
||||
"internal error: file '{path}' was generated more than once"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn template_ids_roundtrip() {
|
||||
for id in TemplateId::all() {
|
||||
assert_eq!(TemplateId::parse(id.as_str()).unwrap(), id);
|
||||
}
|
||||
assert!(TemplateId::parse("cobol").is_err());
|
||||
// Every id is accepted by the parser; only some are implemented.
|
||||
assert!(!TemplateId::Rust.implemented());
|
||||
assert!(TemplateId::Shell.implemented());
|
||||
assert!(TemplateId::Empty.implemented());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_name_validator() {
|
||||
for valid in ["mytool", "my-tool", "my.tool", "my+tool", "a1", "pkh9x"] {
|
||||
assert!(validate_source_name(valid).is_ok(), "{valid} must pass");
|
||||
}
|
||||
for invalid in [
|
||||
"A",
|
||||
"a", // too short
|
||||
"UPPER",
|
||||
"Mixed", // uppercase
|
||||
"-lead", // bad first char
|
||||
"sp ace",
|
||||
"under_score",
|
||||
"bang!",
|
||||
"",
|
||||
"é", // bad charset
|
||||
] {
|
||||
assert!(
|
||||
validate_source_name(invalid).is_err(),
|
||||
"{invalid} must fail"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_name_derives_valid_names() {
|
||||
assert_eq!(sanitize_name("My Tool"), Some("my-tool".to_string()));
|
||||
assert_eq!(sanitize_name("My_Tool"), Some("my-tool".to_string()));
|
||||
assert_eq!(
|
||||
sanitize_name(" spaced out "),
|
||||
Some("spaced-out".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_name("trailing---dashes--"),
|
||||
Some("trailing-dashes".to_string())
|
||||
);
|
||||
assert_eq!(sanitize_name("v1.2_beta"), Some("v1.2-beta".to_string()));
|
||||
// Nothing sane remains.
|
||||
assert_eq!(sanitize_name("---"), None);
|
||||
assert_eq!(sanitize_name("A"), None);
|
||||
assert_eq!(sanitize_name(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_version_validator() {
|
||||
assert!(validate_upstream_version("0.1.0", 1).is_ok());
|
||||
assert!(validate_upstream_version("1.0~rc1", 1).is_ok());
|
||||
assert!(validate_upstream_version("20260101", 1).is_ok());
|
||||
// Not starting with a digit.
|
||||
assert!(validate_upstream_version("v1.0", 1).is_err());
|
||||
// Revision separator inside the upstream version.
|
||||
assert!(validate_upstream_version("1.0-2", 1).is_err());
|
||||
// Invalid characters survive composition.
|
||||
assert!(validate_upstream_version("1.0_0", 1).is_err());
|
||||
// Epochs are accepted and compose fine.
|
||||
assert!(validate_upstream_version("1:2.0", 1).is_ok());
|
||||
// The full version round-trips through DebianVersion.
|
||||
let opts = NewOptions {
|
||||
name: "t".into(),
|
||||
template: TemplateId::Empty,
|
||||
source_dir: SourceDir::Here,
|
||||
upstream_version: "0.1.0".into(),
|
||||
revision: 1,
|
||||
summary: "s".into(),
|
||||
long_description: "s".into(),
|
||||
homepage: None,
|
||||
license: License::Mit,
|
||||
command: "t".into(),
|
||||
maintainer: ("A".into(), "a@b.c".into()),
|
||||
dist: "debian".into(),
|
||||
series: "sid".into(),
|
||||
release: false,
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: false,
|
||||
};
|
||||
assert_eq!(opts.full_version(), "0.1.0-1");
|
||||
assert_eq!(
|
||||
DebianVersion::parse(&opts.full_version()).unwrap().full(),
|
||||
"0.1.0-1"
|
||||
);
|
||||
// Epochs never leak into file names.
|
||||
let epochy = NewOptions {
|
||||
upstream_version: "1:2.0".into(),
|
||||
..opts.clone()
|
||||
};
|
||||
assert_eq!(epochy.upstream_version_no_epoch(), "2.0");
|
||||
assert_eq!(
|
||||
DebianVersion::parse(&epochy.full_version()).unwrap().epoch,
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maintainer_parser() {
|
||||
assert_eq!(
|
||||
parse_maintainer("Jane Doe <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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user