new: scaffold new Debian source packages (non-interactive core)

This commit is contained in:
2026-09-16 12:14:09 +02:00
parent 9c3394750d
commit d044f757e9
12 changed files with 3243 additions and 1 deletions
+943
View File
@@ -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}");
}
}