Files
pkh/src/new/options.rs
T
vhaudiquet a59e99b107 new: type the scaffold validator errors
The leaf validators of the wizard (source name, command, upstream
version, maintainer, homepage, Depends, orig path, template id, file
collisions) now return a shared options::ValidationError carrying the
structured pieces (the rejected string, the wrapped DebianVersion or
Deps parse failure) instead of pre-formatted Strings; the wizard
resolve flow keeps its aggregate String errors and renders at the
boundary, and the Prompter validator closures adapt with to_string()
since the port's Validator deliberately exchanges re-ask explanations
as plain strings. Display texts are unchanged, as are the tests that
assert on them.
2026-10-03 02:17:22 +02:00

1745 lines
65 KiB
Rust

//! 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};
use crate::new::licenses;
use crate::new::origin::{Forge, GitOrigin};
use crate::new::templates;
/// Build systems / project kinds `pkh new` knows about: a lightweight id
/// wrapping the stable CLI string (the `--lang` value).
///
/// The templates themselves are defined by the per-template manifests
/// under `data/templates/<id>/` (see [`crate::new::templates`]): label,
/// detection markers, policy metadata and file bodies all live there, and
/// every id below must have a manifest registered — the registry is built
/// from exactly these constants and a consistency test keeps the two in
/// lockstep.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TemplateId(&'static str);
impl TemplateId {
/// Rust project (`Cargo.toml`).
pub const RUST: TemplateId = TemplateId("rust");
/// Python project (`pyproject.toml` / `setup.py` / `setup.cfg`).
pub const PYTHON: TemplateId = TemplateId("python");
/// C/C++ with Meson (`meson.build`).
pub const MESON: TemplateId = TemplateId("meson");
/// C/C++ with CMake (`CMakeLists.txt`).
pub const CMAKE: TemplateId = TemplateId("cmake");
/// C/C++ with Autotools (`configure.ac`).
pub const AUTOTOOLS: TemplateId = TemplateId("autotools");
/// Go module (`go.mod`).
pub const GO: TemplateId = TemplateId("go");
/// Shell script / single interpreted file.
pub const SHELL: TemplateId = TemplateId("shell");
/// Generic Makefile-based project.
pub const MAKEFILE: TemplateId = TemplateId("makefile");
/// Metapackage / empty base (no build system).
pub const EMPTY: TemplateId = TemplateId("empty");
/// Every template id, in the order offered by the wizard language menu
/// (the registry order of the manifests).
pub fn all() -> &'static [TemplateId] {
templates::ids()
}
/// Canonical CLI identifier of this template.
pub fn as_str(&self) -> &'static str {
self.0
}
/// Parse a CLI identifier, accepting exactly the registered spellings.
pub fn parse(s: &str) -> Result<TemplateId, ValidationError> {
Self::all()
.iter()
.copied()
.find(|id| id.as_str() == s)
.ok_or_else(|| ValidationError::UnknownTemplate { id: s.to_string() })
}
/// Human-readable menu label of this template, as offered by the wizard
/// language question (and reused in the summary screen): the
/// manifest's `label`.
pub fn display_name(&self) -> &'static str {
templates::get(*self).map(|t| t.label()).unwrap_or(self.0)
}
/// The template whose menu label (or CLI identifier) is `label`.
pub fn from_label(label: &str) -> Option<TemplateId> {
templates::from_label(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),
}
impl SourceDir {
/// The directory being packaged, when it already exists on disk (`None`
/// for the skeleton mode, whose sources are rendered in memory).
pub fn existing_dir(&self, cwd: &Path) -> Option<PathBuf> {
match self {
SourceDir::Skeleton => None,
SourceDir::Here => Some(cwd.to_path_buf()),
SourceDir::Path(path) => Some(path.clone()),
}
}
}
/// Debian source format of the scaffolded package: an explicit, derived
/// choice rather than a boolean afterthought.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceFormat {
/// `3.0 (native)`: no orig tarball at all. The default for a fresh,
/// self-authored skeleton.
Native,
/// `3.0 (quilt)`: upstream sources in the orig tarball, packaging in
/// `debian/`. The default when packaging an existing project.
Quilt,
}
impl SourceFormat {
/// The value written to `debian/source/format`.
pub fn deb_string(&self) -> &'static str {
match self {
SourceFormat::Native => super::debian::SOURCE_FORMAT_NATIVE,
SourceFormat::Quilt => super::debian::SOURCE_FORMAT_QUILT,
}
}
}
/// How the orig tarball of a quilt package is produced. The right answer
/// depends on where the upstream sources come from, so the wizard offers
/// them pre-ordered by what git origin detection found (release download
/// and `git archive` only exist when HEAD sits exactly on a tag).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OrigOrigin {
/// Download the upstream release tarball of `tag` from the forge of the
/// `origin` remote and repack it (explicit network consent).
Release {
/// Release tag the tarball is downloaded for.
tag: String,
/// Forge the tarball is downloaded from.
forge: Forge,
},
/// `git archive` the tag HEAD sits on: offline and deterministic.
GitArchive {
/// Release tag archived into the tarball.
tag: String,
},
/// Repack a tarball the user provides (a local path or an http(s) URL).
Provided {
/// Path or URL of the user-provided tarball.
source: String,
},
/// Snapshot the working tree into the orig tarball (the only option for
/// a skeleton, and the fallback when the upstream history is unknown).
Snapshot,
}
impl OrigOrigin {
/// Human-readable description used in the wizard summary and the
/// post-scaffold report.
pub fn label(&self) -> String {
match self {
OrigOrigin::Release { tag, .. } => format!("release download ({tag})"),
OrigOrigin::GitArchive { tag } => format!("git archive ({tag})"),
OrigOrigin::Provided { source } => format!("user tarball ({source})"),
OrigOrigin::Snapshot => "working tree snapshot".to_string(),
}
}
}
/// Upstream license of the package: a curated SPDX list plus a free-text
/// fallback for anything else (including "unknown" until the user picks one).
/// The curated identifiers, their accepted spellings and the menu labels
/// come from the bundled license table (`data/licenses.yml`); this enum
/// stays code because [`NewOptions`] and the template rendering match on
/// its variants — a consistency test keeps the two in lockstep.
#[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`]: the curated SPDX identifiers
/// of the bundled license table (`data/licenses.yml`, matched through
/// their spellings, case-insensitively) become their variant,
/// anything else becomes [`License::Custom`] verbatim (lowercased).
pub fn parse(s: &str) -> License {
match licenses::entry_for_spelling(s) {
Some(entry) => Self::from_spdx(&entry.id)
.expect("licenses.yml ids and License variants are kept in sync by a test"),
None => License::Custom(s.to_ascii_lowercase()),
}
}
/// The variant of a curated license's SPDX identifier — the inverse of
/// [`License::spdx`] for the non-`Custom` variants, `None` for any
/// other string. The ids accepted here are locked to the bundled
/// license table by a consistency test.
fn from_spdx(id: &str) -> Option<License> {
match id {
"MIT" => Some(License::Mit),
"Apache-2.0" => Some(License::Apache2),
"GPL-2.0+" => Some(License::Gpl2Plus),
"GPL-3.0+" => Some(License::Gpl3Plus),
"LGPL-2.1+" => Some(License::Lgpl21Plus),
"LGPL-3.0+" => Some(License::Lgpl3Plus),
"BSD-2-Clause" => Some(License::Bsd2Clause),
"BSD-3-Clause" => Some(License::Bsd3Clause),
"ISC" => Some(License::Isc),
_ => None,
}
}
/// 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: the
/// identifier substituted into the URL template of the bundled
/// license table.
pub fn spdx_url(&self) -> String {
licenses::url_template().replace("{id}", 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>,
/// Debian source format: `3.0 (native)` for a fresh skeleton by
/// default, `3.0 (quilt)` when packaging an existing project; either
/// can be forced with `--native` / `--quilt`.
pub source_format: SourceFormat,
/// How the orig tarball is produced (quilt only; `None` with the native
/// format, which has no orig tarball).
pub orig: Option<OrigOrigin>,
/// 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,
/// `--quilt` (mutually exclusive with `--native`).
pub quilt: bool,
/// `--orig-from <release|git|path|snapshot>`.
pub orig_from: Option<String>,
/// `--orig-path <file|url>` (required by `--orig-from path`).
pub orig_path: Option<String>,
/// True unless `--no-git`.
pub git: bool,
/// `--defaults`.
pub defaults: bool,
}
/// Failure of one of the scaffold option validators ([`validate_source_name`],
/// [`validate_command`], [`validate_upstream_version`], [`parse_maintainer`],
/// [`validate_homepage`], [`validate_depends`], [`validate_orig_path`],
/// [`check_file_collisions`], [`TemplateId::parse`]): what the wizard re-asks
/// about, carrying the pieces callers may want to match on.
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
/// The source package name is shorter than dpkg's 2-character minimum.
#[error("'{name}' is too short: package names need at least 2 characters")]
SourceNameTooShort {
/// The rejected name.
name: String,
},
/// The source package name is outside dpkg's charset or shape.
#[error(
"'{}' is not a valid package name: names must be lowercase, start with a letter or digit and may only contain [a-z0-9+.-]",
name
)]
InvalidSourceName {
/// The rejected name.
name: String,
},
/// No installed command was given.
#[error("the command name must not be empty")]
EmptyCommand,
/// The command name is not a lowercase identifier.
#[error(
"'{}' is not a valid command name: commands must be a lowercase identifier (letters, digits, + - . _), e.g. 'mytool'",
command
)]
InvalidCommand {
/// The rejected command name.
command: String,
},
/// Upstream versions should start with a digit (dpkg recommendation,
/// enforced here).
#[error("upstream versions should start with a digit")]
UpstreamNotDigit,
/// `-` is the Debian revision separator.
#[error(
"upstream versions must not contain '-': the Debian revision is appended automatically"
)]
UpstreamContainsDash,
/// The upstream version composed with the Debian revision does not
/// parse as a Debian version.
#[error("'{version}' is not a valid Debian version: {source}")]
InvalidVersion {
/// The composed `upstream-revision` string.
version: String,
/// The version parse failure.
source: crate::debian::version::VersionParseError,
},
/// The maintainer is not of the form `Name <email>`.
#[error("'{s}' is not a valid maintainer: expected the form 'Name <email>'")]
MaintainerShape {
/// The rejected maintainer string.
s: String,
},
/// The maintainer name (before the angle brackets) is empty.
#[error("'{s}': the maintainer name must not be empty")]
MaintainerNameEmpty {
/// The rejected maintainer string.
s: String,
},
/// The maintainer email does not carry exactly one `@`.
#[error("'{s}': the maintainer email must contain exactly one '@'")]
MaintainerEmailAt {
/// The rejected maintainer string.
s: String,
},
/// The maintainer email carries whitespace.
#[error("'{s}': the maintainer email must not contain whitespace")]
MaintainerEmailWhitespace {
/// The rejected maintainer string.
s: String,
},
/// The homepage is not an http(s) URL.
#[error("'{url}' is not a valid homepage: expected an http:// or https:// URL")]
InvalidHomepage {
/// The rejected URL.
url: String,
},
/// The Depends list does not parse as package relations.
#[error("Invalid Depends list '{list}': {source}")]
InvalidDepends {
/// The rejected list.
list: String,
/// The relation parse failure.
source: crate::debian::deps::ParseError,
},
/// The orig tarball is neither an http(s) URL nor an existing file.
#[error(
"'{path}' is not an http(s) URL and not an existing file: --orig-path must point at a tarball to repack"
)]
InvalidOrigPath {
/// The rejected path or URL.
path: String,
},
/// The language/template id is not one of the registered spellings.
#[error("Unknown language/template '{}'. Supported values are: {}.", id, TemplateId::all().iter().map(|id| id.as_str()).collect::<Vec<_>>().join(", "))]
UnknownTemplate {
/// The unknown id.
id: String,
},
/// A template generated the same file twice.
#[error("internal error: file '{path}' was generated more than once")]
GeneratedTwice {
/// The duplicated path.
path: String,
},
}
/// 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<(), ValidationError> {
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(ValidationError::SourceNameTooShort {
name: name.to_string(),
});
}
if !regex.is_match(name) {
return Err(ValidationError::InvalidSourceName {
name: name.to_string(),
});
}
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 the installed command (binary) name: a non-empty, ASCII-only,
/// lowercase identifier (`^[a-z0-9][a-z0-9+.\-_]*$`), matching what
/// dpkg/devscripts accept for executable names in practice. The name is
/// interpolated verbatim into `debian/install`, `debian/rules`, the
/// autopkgtest smoke test, automake variables (`{command}_SOURCES`),
/// `meson.build` and the `[project.scripts]` table, so uppercase letters,
/// whitespace, quotes and shell metacharacters are rejected here instead of
/// generating broken install lines and build files.
pub fn validate_command(command: &str) -> Result<(), ValidationError> {
static COMMAND_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let regex = COMMAND_REGEX.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9+.\-_]*$").unwrap());
if command.is_empty() {
return Err(ValidationError::EmptyCommand);
}
if !regex.is_match(command) {
return Err(ValidationError::InvalidCommand {
command: command.to_string(),
});
}
Ok(())
}
/// 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<(), ValidationError> {
if !upstream.starts_with(|c: char| c.is_ascii_digit()) {
return Err(ValidationError::UpstreamNotDigit);
}
if upstream.contains('-') {
return Err(ValidationError::UpstreamContainsDash);
}
let composed = format!("{}-{}", upstream, revision);
DebianVersion::parse(&composed)
.map(|_| ())
.map_err(|source| ValidationError::InvalidVersion {
version: composed.clone(),
source,
})
}
/// 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), ValidationError> {
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(|| ValidationError::MaintainerShape { s: s.to_string() })?;
let name = caps[1].trim().to_string();
let email = caps[2].trim().to_string();
if name.is_empty() {
return Err(ValidationError::MaintainerNameEmpty { s: s.to_string() });
}
if !email.contains('@') || email.matches('@').count() > 1 {
return Err(ValidationError::MaintainerEmailAt { s: s.to_string() });
}
if email.chars().any(char::is_whitespace) {
return Err(ValidationError::MaintainerEmailWhitespace { s: s.to_string() });
}
Ok((name, email))
}
/// Validate a homepage URL: it must carry an `http(s)://` prefix.
pub fn validate_homepage(url: &str) -> Result<(), ValidationError> {
if url.starts_with("http://") || url.starts_with("https://") {
Ok(())
} else {
Err(ValidationError::InvalidHomepage {
url: url.to_string(),
})
}
}
/// 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>, ValidationError> {
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(|source| ValidationError::InvalidDepends {
list: list.to_string(),
source,
})?;
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).map_err(|e| e.to_string())?),
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).map_err(|e| e.to_string())?;
// Source format: explicit flags win (--native / --quilt, mutually
// exclusive), otherwise the mode decides — a fresh, self-authored
// skeleton is native by default, an existing project is quilt.
let source_format = match (cli.native, cli.quilt) {
(true, true) => {
return Err("--native and --quilt are mutually exclusive".to_string());
}
(true, false) => SourceFormat::Native,
(false, true) => SourceFormat::Quilt,
(false, false) => match source_dir {
SourceDir::Skeleton => SourceFormat::Native,
SourceDir::Here | SourceDir::Path(_) => SourceFormat::Quilt,
},
};
// Git origin state of the packaged directory: needed for the version
// default (tag / +git scheme) and for the orig-tarball plan. Probing is
// purely local (git config and refs, no network).
let existing_dir = source_dir.existing_dir(&std::env::current_dir().unwrap_or_default());
let needs_origin = cli.upstream_version.is_none()
|| (source_format == SourceFormat::Quilt
&& (cli.orig_from.is_some() || existing_dir.is_some()));
let origin = if needs_origin {
existing_dir.as_deref().and_then(GitOrigin::detect)
} else {
None
};
// Upstream version default: probed project version, else the tag HEAD
// sits on, else `<lasttag>+git<YYYYMMDD>.<hash>`, else 0.1.0.
let upstream_version = match cli.upstream_version {
Some(ref version) => version.clone(),
None => {
let probed = template
.and_then(templates::get)
.and_then(|t| t.probe(existing_dir.as_deref()?))
.and_then(|probe| probe.version)
.filter(|version| {
validate_upstream_version(version, cli.revision.unwrap_or(1)).is_ok()
});
let derived = probed
.or_else(|| origin.as_ref().and_then(GitOrigin::head_tag_version))
.or_else(|| origin.as_ref().and_then(GitOrigin::git_version))
.unwrap_or_else(|| "0.1.0".to_string());
if derived != "0.1.0" {
log::info!("No --upstream-version given, defaulting to '{derived}'");
}
derived
}
};
let revision = cli.revision.unwrap_or(1);
validate_upstream_version(&upstream_version, revision)
.map_err(|e| format!("Invalid upstream version: {e}"))?;
// Orig-tarball plan (quilt only). Without an explicit --orig-from the
// non-interactive default never touches the network: `git archive` of
// the tag when HEAD sits on one, a working-tree snapshot otherwise.
let orig = resolve_orig_origin(&cli, &source_dir, source_format, origin.as_ref())?;
let homepage = match &cli.homepage {
Some(h) => {
validate_homepage(h).map_err(|e| e.to_string())?;
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());
// The default is the already-validated package name (a subset of the
// command charset), so only an explicit --command/wizard answer can fail.
validate_command(&command).map_err(|e| e.to_string())?;
let maintainer = match &cli.maintainer {
Some(m) => parse_maintainer(m).map_err(|e| e.to_string())?,
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).map_err(|e| e.to_string())?
};
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,
source_format,
orig,
git: cli.git,
autopkgtest: false,
pkg_config: false,
watch: None,
})
}
/// Resolve the orig-tarball plan of a quilt scaffold from the CLI answers
/// and the detected git origin. Fails early with a pointed message when an
/// explicit `--orig-from` mode cannot apply to this tree; the implicit,
/// non-interactive default never touches the network (`git archive` of the
/// tag when HEAD sits exactly on one, a working-tree snapshot otherwise).
fn resolve_orig_origin(
cli: &NewCli,
source_dir: &SourceDir,
source_format: SourceFormat,
origin: Option<&GitOrigin>,
) -> Result<Option<OrigOrigin>, String> {
if source_format == SourceFormat::Native {
if cli.orig_from.is_some() || cli.orig_path.is_some() {
return Err(
"--orig-from/--orig-path need a quilt package (drop --native \
or pass --quilt to build with an orig tarball)"
.to_string(),
);
}
return Ok(None);
}
let skeleton = matches!(source_dir, SourceDir::Skeleton);
let head_tag = || origin.and_then(|o| o.head_tag.clone());
let plan = match cli.orig_from.as_deref() {
Some("snapshot") => OrigOrigin::Snapshot,
Some("path") => {
let source = cli.orig_path.clone().ok_or_else(|| {
"--orig-from path requires --orig-path <tarball file or URL>".to_string()
})?;
validate_orig_path(&source).map_err(|e| e.to_string())?;
OrigOrigin::Provided { source }
}
Some("git") => {
if skeleton {
return Err("a fresh skeleton has no upstream git history; use \
--orig-from snapshot (or drop --quilt)"
.to_string());
}
match head_tag() {
Some(tag) => OrigOrigin::GitArchive { tag },
None => {
return Err("--orig-from git requires HEAD to be exactly on a \
release tag"
.to_string());
}
}
}
Some("release") => {
if skeleton {
return Err("a fresh skeleton has no upstream release to download; \
use --orig-from snapshot (or drop --quilt)"
.to_string());
}
let Some(tag) = head_tag() else {
return Err("--orig-from release requires HEAD to be exactly on a \
release tag"
.to_string());
};
let forge = origin.and_then(|o| o.forge.clone()).ok_or_else(|| {
"--orig-from release needs a github.com or gitlab.com origin \
remote (self-hosted forges are not recognized)"
.to_string()
})?;
OrigOrigin::Release { tag, forge }
}
Some(other) => {
return Err(format!(
"Unknown --orig-from value '{other}'. Supported: release, \
git, path, snapshot."
));
}
None => {
// Skeletons (and anything without an upstream history) can only
// be snapshotted; on a tag, git archive is the offline default.
match (skeleton, head_tag()) {
(false, Some(tag)) => OrigOrigin::GitArchive { tag },
_ => OrigOrigin::Snapshot,
}
}
};
if cli.orig_path.is_some() && !matches!(plan, OrigOrigin::Provided { .. }) {
return Err("--orig-path is only valid together with --orig-from path".to_string());
}
Ok(Some(plan))
}
/// Validate a `--orig-path` value (and the wizard's tarball answer): either
/// an http(s) URL (downloaded at scaffold time) or an existing local
/// tarball.
pub fn validate_orig_path(source: &str) -> Result<(), ValidationError> {
if source.starts_with("http://") || source.starts_with("https://") {
return Ok(());
}
if Path::new(source).is_file() {
return Ok(());
}
Err(ValidationError::InvalidOrigPath {
path: source.to_string(),
})
}
/// Set of names no template may produce twice (collision check).
pub(crate) fn check_file_collisions(paths: &[String]) -> Result<(), ValidationError> {
let mut seen: HashSet<&String> = HashSet::new();
for path in paths {
if !seen.insert(path) {
return Err(ValidationError::GeneratedTwice {
path: path.to_string(),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn template_ids_roundtrip() {
for id in TemplateId::all().iter().copied() {
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"
);
}
}
/// The command name is interpolated verbatim into debian/install,
/// debian/rules, the smoke test, automake variables, meson.build and
/// [project.scripts], so only the safe identifier charset passes.
#[test]
fn command_validator() {
for valid in [
"mytool",
"my.tool", // automake/TOML-friendly spellings in use in Debian
"my+tool",
"my_tool",
"my-tool",
"2ping", // leading digit
"a", // single character
"a1.b+c-d_e",
] {
assert!(validate_command(valid).is_ok(), "{valid} must pass");
}
for invalid in [
"my tool", // whitespace breaks install lines
"a\"b", // quotes break shell snippets
"MyTool", // uppercase
"-lead", // bad first char
"_lead", // bad first char
".dot", // bad first char
"café", // non-ASCII
"a:b", // shell metacharacter
"a/b", // path separator
"", // empty
] {
assert!(validate_command(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(),
source_format: SourceFormat::Quilt,
orig: None,
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"
);
}
/// The bundled license table and the `License` enum are two halves of
/// one list and must never drift apart: every entry id must map to a
/// variant (and the entry must accept its own id as a spelling, so
/// the menu answers parse back), every variant must have an entry,
/// and the counts must agree. The hardcoded variant list here is the
/// lock: adding a variant or a YAML entry without the other half
/// fails this test.
#[test]
fn licenses_table_and_enum_are_in_sync() {
let variants = [
License::Mit,
License::Apache2,
License::Gpl2Plus,
License::Gpl3Plus,
License::Lgpl21Plus,
License::Lgpl3Plus,
License::Bsd2Clause,
License::Bsd3Clause,
License::Isc,
];
assert_eq!(
licenses::entries().len(),
variants.len(),
"licenses.yml and the License enum carry a different number of licenses"
);
for variant in variants {
let entry = licenses::entries()
.iter()
.find(|entry| entry.id == variant.spdx())
.unwrap_or_else(|| panic!("no licenses.yml entry for '{}'", variant.spdx()));
assert_eq!(entry.id, variant.spdx());
assert!(
entry
.spellings
.iter()
.any(|s| s.eq_ignore_ascii_case(&entry.id)),
"entry '{}' must accept its own id as a spelling",
entry.id
);
// The menu label parses back into the same variant.
assert_eq!(License::parse(&entry.menu), variant);
}
for entry in licenses::entries() {
let variant = License::from_spdx(&entry.id).unwrap_or_else(|| {
panic!("licenses.yml entry '{}' has no License variant", entry.id)
});
assert_eq!(variant.spdx(), entry.id);
}
}
#[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()
.to_string();
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);
// An existing project (Path mode) is quilt by default; an empty,
// non-git directory has no better orig plan than a tree snapshot.
assert_eq!(opts.source_format, SourceFormat::Quilt);
assert_eq!(opts.orig, Some(OrigOrigin::Snapshot));
// 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}");
}
/// Minimal CLI answers for a scripted source directory.
fn cli_for(dir: &std::path::Path) -> NewCli {
NewCli {
name: Some("mytool".into()),
source: Some(dir.to_path_buf()),
lang: Some("empty".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
git: true,
..Default::default()
}
}
/// A git repo fixture: init, commit, optional tag, optional extra
/// commit (needs the git CLI; returns (repo dir, HEAD short hash)).
fn git_repo(tag: Option<&str>, second_commit: bool) -> (tempfile::TempDir, String) {
let run = |dir: &std::path::Path, args: &[&str]| {
let status = std::process::Command::new("git")
.args([
"-c",
"user.name=T",
"-c",
"user.email=t@example.invalid",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(dir)
.status()
.expect("git should be runnable");
assert!(status.success(), "git {args:?} failed");
};
let dir = tempfile::tempdir().unwrap();
run(dir.path(), &["init", "-q"]);
std::fs::write(dir.path().join("f"), "one\n").unwrap();
run(dir.path(), &["add", "f"]);
run(dir.path(), &["commit", "-q", "-m", "first"]);
if let Some(tag) = tag {
run(dir.path(), &["tag", tag]);
}
if second_commit {
std::fs::write(dir.path().join("f"), "two\n").unwrap();
run(dir.path(), &["add", "f"]);
run(dir.path(), &["commit", "-q", "-m", "second"]);
}
let hash = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(dir.path())
.output()
.unwrap();
let hash = String::from_utf8_lossy(&hash.stdout).trim().to_string();
(dir, hash)
}
#[tokio::test]
async fn resolve_skeleton_is_native_without_orig_tarball() {
// A self-authored skeleton defaults to 3.0 (native): no orig
// tarball, no orig plan.
let cli = NewCli {
name: Some("mytool".into()),
lang: Some("shell".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
git: true,
..Default::default()
};
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.source_format, SourceFormat::Native);
assert_eq!(opts.orig, None);
}
#[tokio::test]
async fn resolve_skeleton_forced_quilt_snapshots_the_tree() {
let cli = NewCli {
name: Some("mytool".into()),
lang: Some("shell".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
quilt: true,
git: true,
..Default::default()
};
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.source_format, SourceFormat::Quilt);
assert_eq!(opts.orig, Some(OrigOrigin::Snapshot));
}
#[tokio::test]
async fn resolve_skeleton_rejects_history_dependent_orig_modes() {
// A skeleton has no upstream git history: only the snapshot mode
// makes sense.
for mode in ["git", "release"] {
let cli = NewCli {
name: Some("mytool".into()),
lang: Some("shell".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
quilt: true,
orig_from: Some(mode.to_string()),
git: true,
..Default::default()
};
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("no upstream"), "{mode}: {err}");
}
}
#[tokio::test]
async fn resolve_existing_project_forced_native() {
let dir = tempfile::tempdir().unwrap();
let mut cli = cli_for(dir.path());
cli.native = true;
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.source_format, SourceFormat::Native);
assert_eq!(opts.orig, None);
}
#[tokio::test]
async fn resolve_native_and_quilt_conflict() {
let cli = NewCli {
name: Some("mytool".into()),
lang: Some("shell".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
native: true,
quilt: true,
..Default::default()
};
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("mutually exclusive"), "{err}");
}
#[tokio::test]
async fn resolve_head_on_tag_defaults_to_git_archive_and_tag_version() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let (dir, _hash) = git_repo(Some("v1.2.3"), false);
let opts = resolve(cli_for(dir.path())).await.unwrap();
assert_eq!(opts.source_format, SourceFormat::Quilt);
// Version default: the tag version, not 0.1.0.
assert_eq!(opts.upstream_version, "1.2.3");
// Non-interactive orig default on a tag: git archive (no network).
assert_eq!(
opts.orig,
Some(OrigOrigin::GitArchive {
tag: "v1.2.3".to_string()
})
);
}
#[tokio::test]
async fn resolve_between_releases_snapshots_with_git_version() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let (dir, _hash) = git_repo(Some("v1.2.3"), true);
let opts = resolve(cli_for(dir.path())).await.unwrap();
let expected = crate::new::origin::GitOrigin::detect(dir.path())
.unwrap()
.git_version()
.unwrap();
assert_eq!(opts.upstream_version, expected);
assert!(opts.upstream_version.starts_with("1.2.3+git"));
assert_eq!(opts.orig, Some(OrigOrigin::Snapshot));
}
#[tokio::test]
async fn resolve_probed_project_version_beats_the_tag() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let (dir, _) = git_repo(Some("v9.9.9"), false);
// A Cargo.toml without src/ makes `cargo metadata` fail, so the
// template probe falls back to its deterministic line parse.
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"x\"\nversion = \"3.3.3\"\n",
)
.unwrap();
let mut cli = cli_for(dir.path());
cli.lang = Some("rust".into());
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.upstream_version, "3.3.3");
// The orig plan still comes from the tag.
assert_eq!(
opts.orig,
Some(OrigOrigin::GitArchive {
tag: "v9.9.9".to_string()
})
);
}
#[tokio::test]
async fn resolve_orig_from_modes() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let (dir, _) = git_repo(Some("v1.2.3"), false);
// snapshot is always allowed.
let mut cli = cli_for(dir.path());
cli.orig_from = Some("snapshot".into());
assert_eq!(resolve(cli).await.unwrap().orig, Some(OrigOrigin::Snapshot));
// release requires a recognized forge remote.
let mut cli = cli_for(dir.path());
cli.orig_from = Some("release".into());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("github.com or gitlab.com"), "{err}");
// After adding a recognized remote the release plan resolves.
std::process::Command::new("git")
.args(["remote", "add", "origin", "https://github.com/foo/bar.git"])
.current_dir(dir.path())
.status()
.unwrap()
.success()
.then_some(())
.unwrap();
let mut cli = cli_for(dir.path());
cli.orig_from = Some("release".into());
assert_eq!(
resolve(cli).await.unwrap().orig,
Some(OrigOrigin::Release {
tag: "v1.2.3".to_string(),
forge: crate::new::origin::Forge::parse("https://github.com/foo/bar.git").unwrap(),
})
);
// path requires --orig-path, and an existing file or a URL.
let mut cli = cli_for(dir.path());
cli.orig_from = Some("path".into());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("--orig-path"), "{err}");
let tarball = dir.path().join("upstream.tar.gz");
std::fs::write(&tarball, b"not really a tarball").unwrap();
let mut cli = cli_for(dir.path());
cli.orig_from = Some("path".into());
cli.orig_path = Some(tarball.to_string_lossy().into_owned());
assert_eq!(
resolve(cli).await.unwrap().orig,
Some(OrigOrigin::Provided {
source: tarball.to_string_lossy().into_owned()
})
);
let mut cli = cli_for(dir.path());
cli.orig_from = Some("path".into());
cli.orig_path = Some("https://example.com/upstream.tar.gz".into());
assert_eq!(
resolve(cli).await.unwrap().orig,
Some(OrigOrigin::Provided {
source: "https://example.com/upstream.tar.gz".to_string()
})
);
// A missing local path is rejected early.
let mut cli = cli_for(dir.path());
cli.orig_from = Some("path".into());
cli.orig_path = Some(
dir.path()
.join("missing.tar.gz")
.to_string_lossy()
.into_owned(),
);
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("not an http(s) URL"), "{err}");
// --orig-path without --orig-from path is a misuse.
let mut cli = cli_for(dir.path());
cli.orig_path = Some(tarball.to_string_lossy().into_owned());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("only valid together"), "{err}");
// git requires HEAD exactly on a tag.
let (off, _) = git_repo(Some("v1.0.0"), true);
let mut cli = cli_for(off.path());
cli.orig_from = Some("git".into());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("exactly on a release tag"), "{err}");
// ... and the orig flags need quilt.
let mut cli = cli_for(dir.path());
cli.native = true;
cli.orig_from = Some("git".into());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("need a quilt package"), "{err}");
}
}